
Translate Book
- 1.1k installs
- 1k repo stars
- Updated July 20, 2026
- deusyu/translate-book
translate-book is an agent skill that translates full-length books and long documents from PDF, DOCX, or EPUB sources into another language using a parallel sub-agent pipeline for developers who need chunked Markdown tra
About
translate-book is an agent skill from deusyu/translate-book that translates entire books across languages by orchestrating a multi-step pipeline with parallel sub-agents. Inputs include PDF, DOCX, and EPUB files that convert into Markdown chunks, pass through translated chunks, and reassemble into HTML, DOCX, EPUB, or PDF deliverables. Required binaries include python3 and pandoc, with ebook-convert from Calibre as an anyBins alternative for ebook handling. Allowed tools span Read, Write, Edit, Bash, Glob, Grep, Agent, and AskUserQuestion so the workflow can fan out translation work safely. Developers reach for translate-book when localization or documentation teams need book-scale translation inside an agent session instead of manual copy-paste through desktop translation tools. The skill collects source language, target language, and output format up front, then drives chunking and parallel translation until publication-ready files exist.
- Orchestrates parallel sub-agents to translate books from PDF, DOCX, or EPUB
- Converts input → Markdown chunks → translated chunks → final HTML/DOCX/EPUB/PDF
- Supports 8 concurrent sub-agents by default with adjustable concurrency
- Automatically handles preprocessing, chunking, translation, and reassembly
- Requires Python3, Pandoc, and Calibre/ebook-convert on the host system
Translate Book by the numbers
- 1,088 all-time installs (skills.sh)
- +54 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #977 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/deusyu/translate-book --skill translate-bookAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.1k |
|---|---|
| repo stars | ★ 1k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 20, 2026 |
| Repository | deusyu/translate-book ↗ |
How do you translate a full book with agents?
Translate full-length books and long documents into another language using a reliable multi-agent pipeline.
Who is it for?
Developers localizing long technical books or documentation who need a pandoc-backed multi-agent translation pipeline with multiple export formats.
Skip if: Short UI string localization, real-time chat translation, or environments missing python3, pandoc, or Calibre ebook-convert.
When should I use this skill?
The developer asks to translate a PDF, DOCX, or EPUB book or long document into another language with structured outputs.
What you get
Translated Markdown chunks plus rebuilt HTML, DOCX, EPUB, or PDF book files in the target language.
- Translated book files
- Markdown chunk intermediates
By the numbers
- Supports 3 input formats: PDF, DOCX, EPUB
- Exports 4 output formats: HTML, DOCX, EPUB, PDF
Files
Book Translation Skill
You are a book translation assistant. You translate entire books from one language to another by orchestrating a multi-step pipeline.
Workflow
1. Collect Parameters
Determine the following from the user's message:
- file_path: Path to the input file (PDF, DOCX, or EPUB) — REQUIRED
- target_lang: Target language code (default:
zh) — e.g. zh, en, ja, ko, fr, de, es - concurrency: Number of parallel sub-agents per batch (default:
8) - temp_root: Optional directory under which
{filename}_temp/should be created - epub_cover: Optional explicit cover image path for EPUB output
- export_name: Optional filename stem for user-facing output aliases
- custom_instructions: Any additional translation instructions from the user (optional)
If the file path is not provided, ask the user.
2. Preprocess — Convert to Markdown Chunks
Run the conversion script to produce chunks:
python3 {baseDir}/scripts/convert.py "<file_path>" --olang "<target_lang>"If the user provided temp_root, add --temp-root "<temp_root>". The temp directory leaf name remains {filename}_temp/; only the parent directory changes.
This creates a {filename}_temp/ directory containing:
input.html,input.md— intermediate fileschunk0001.md,chunk0002.md, ... — source chunks for translationmanifest.json— chunk manifest for tracking and validationconfig.txt— pipeline configuration with metadata
3. Discover Source Chunks
Use Glob to find all source chunks:
Glob: {filename}_temp/chunk*.mdExclude output_chunk*.md from the source list. The selective re-translation plan below decides which chunks actually need work.
3.5. Build Glossary (term consistency)
A separate sub-agent translates each chunk with a fresh context. Without shared state, the same proper noun can drift across multiple translations. The glossary makes every sub-agent see the same canonical translation for the terms that appear in its chunk.
If <temp_dir>/glossary.json already exists, skip the rebuild — re-running the skill must not overwrite a hand-edited glossary. To force a rebuild, delete the file.
Otherwise:
1. Sample chunks: read chunk0001.md, the last chunk, and 3 evenly-spaced middle chunks. If chunk_count < 5, sample all of them. 2. Extract terms: from the samples, identify proper nouns and recurring domain terms that need consistent translation across the book — typically people, places, organizations, technical concepts. Translate each into the target language. Skip generic vocabulary that any translator would render the same way. 3. Write `glossary.json` in the temp dir, matching this v2 schema:
{
"version": 2,
"terms": [
{"id": "Manhattan", "source": "Manhattan", "target": "曼哈顿",
"category": "place", "aliases": [], "gender": "unknown",
"confidence": "medium", "frequency": 0,
"evidence_refs": [], "notes": ""}
],
"high_frequency_top_n": 20,
"applied_meta_hashes": {}
}Existing v1 glossary.json files are auto-upgraded to v2 on first load. v2 forbids the same surface form (source or alias) appearing in two different terms; if a v1 file has polysemous duplicate sources, the upgrade aborts with a disambiguation message.
4. Count frequencies by running:
python3 {baseDir}/scripts/glossary.py count-frequencies "<temp_dir>"This scans every chunk*.md (excluding output_chunk*.md), updates each term's frequency field, and writes back atomically.
The glossary is hand-editable. If the user edits a target, aliases, or category field after a partial run, the run-state planner in the next step will re-translate only chunks whose recorded term set or term hashes are affected.
3.7. Plan Selective Re-translation
Run:
python3 {baseDir}/scripts/run_state.py plan "<temp_dir>"If the user explicitly asks to apply glossary edits to outputs produced before run_state.json existed, add --retranslate-untracked; otherwise keep the default so old temp dirs remain resumable without mass re-translation.
Capture stdout JSON:
translation_chunk_ids— chunks to translate in this run.record_only_chunk_ids— existing valid outputs that needrun_state.json
records but do not need translation.
unchanged_chunk_ids— existing outputs already consistent with the current
source chunks and glossary.
If record_only_chunk_ids is non-empty, record them before launching sub-agents:
python3 {baseDir}/scripts/run_state.py record "<temp_dir>" chunk0001 chunk0002 ...Use translation_chunk_ids as the work queue for Step 4. If it is empty, skip to Step 5.
4. Parallel Translation with Sub-Agents
Each chunk gets its own independent sub-agent (1 chunk = 1 sub-agent = 1 fresh context). This prevents context accumulation and output truncation.
Launch chunks in batches to respect API rate limits:
- Each batch: up to
concurrencysub-agents in parallel (default: 8) - Wait for the current batch to complete before launching the next
Spawn each sub-agent with the following task. Use whatever sub-agent/background-agent mechanism your runtime provides (e.g. the Agent tool, sessions_spawn, or equivalent).
The output file is output_ prefixed to the source filename: chunk0001.md → output_chunk0001.md.
Translate the file<temp_dir>/chunk<NNNN>.mdto {TARGET_LANGUAGE} and write the result to<temp_dir>/output_chunk<NNNN>.md. Follow the translation rules below. Output only the translated content — no commentary.
Each sub-agent receives:
- The single chunk file it is responsible for
- The temp directory path
- The target language
- The translation prompt (see below)
- A per-chunk term table (see "Term table assembly" below)
- Read-only neighboring chunk excerpts (see "Neighbor context assembly" below)
- Any custom instructions
Term table assembly — before spawning a sub-agent, run:
python3 {baseDir}/scripts/glossary.py print-terms-for-chunk "<temp_dir>" "chunk<NNNN>.md"Capture stdout. The CLI emits a 3-column markdown table (原文 | 别名 | 译文) of every term that either appears in this chunk (by source OR any alias) OR is in the top-N most-frequent terms book-wide. Inject the table as {TERM_TABLE} in rule #13 of the translation prompt. If stdout is empty (no glossary, or no relevant terms), omit rule #13 from this chunk's prompt entirely — do not leave a dangling {TERM_TABLE} placeholder.
Neighbor context assembly — before spawning a sub-agent, run:
python3 {baseDir}/scripts/chunk_context.py "<temp_dir>" "chunk<NNNN>.md"Capture stdout. The CLI emits prompt-ready read-only excerpts: the last ~300 characters of the previous chunk and the first ~300 characters of the next chunk when those files exist. Inject this block as {NEIGHBOR_CONTEXT}. If stdout is empty, omit the neighbor-context block entirely. The sub-agent must not translate neighboring excerpts or copy them into the output; they are only for pronoun, gender, and entity-resolution context.
Each sub-agent's task: 1. Read the source chunk file (e.g. chunk0001.md) 2. Translate the content following the translation rules below 3. Write the translated content to output_chunk0001.md 4. Write observations to output_chunk0001.meta.json matching the schema below. Non-blocking — leave fields empty if unsure; do not invent entities. Always emit the file (even if all arrays are empty), because its presence + content hash is how the main agent tracks whether feedback was already merged.
Sub-agent meta schema (output_chunk<NNNN>.meta.json):
{
"schema_version": 1,
"new_entities": [
{"source": "Taig", "target_proposal": "泰格", "category": "person",
"evidence": "<≤200-char quote from the chunk>"}
],
"alias_hypotheses": [
{"variant": "Taig", "may_be_alias_of_source": "Tai",
"evidence": "<≤200-char quote>"}
],
"attribute_hypotheses": [
{"entity_source": "Tai", "attribute": "gender", "value": "male",
"confidence": "high", "evidence": "<≤200-char quote>"}
],
"used_term_sources": ["Tai", "Manhattan"],
"conflicts": [
{"entity_source": "Tai", "field": "target", "injected": "泰",
"observed_better": "太一", "evidence": "<≤200-char quote>"}
]
}Do NOT include a `chunk_id` field — chunk identity is derived from the filename. Putting it in the payload creates a hallucination hole and validation will reject the file.
The meta file is read by the main agent later and merged into glossary.json (see merge_meta.py). Sub-agents should fill the schema honestly: cite real quotes from the chunk, never invent entities to "look productive". An empty meta is a perfectly valid output.
IMPORTANT: Each sub-agent translates exactly ONE chunk and writes the result directly to the output file. No START/END markers needed.
Translation Prompt for Sub-Agents
Include this translation prompt in each sub-agent's instructions (replace {TARGET_LANGUAGE} with the actual language name, e.g. "Chinese"):
---
请翻译markdown文件为 {TARGET_LANGUAGE}. IMPORTANT REQUIREMENTS: 1. 严格保持 Markdown 格式不变,包括标题、链接、图片引用等 2. 仅翻译文字内容,保留所有 Markdown 语法和文件名 3. 删除空链接、不必要的字符和如: 行末的'\\'。页码已由 convert.py 上游处理,不要再删除独立的数字行(可能是年份 1984、章节编号、引用编号等正文内容)。 4. 保证格式和语义准确翻译内容自然流畅 5. 只输出翻译后的正文内容,不要有任何说明、提示、注释或对话内容。 6. 表达清晰简洁,不要使用复杂的句式。请严格按顺序翻译,不要跳过任何内容。 7. 必须保留所有图片引用,包括:
- 所有 !alt 格式的图片引用必须完整保留
- 图片文件名和路径不要修改(如 media/image-001.png)
- 图片alt文本可以翻译,但必须保留图片引用结构
- 不要删除、过滤或忽略任何图片相关内容
- 图片引用示例:!Figure 1: Data Flow -> !图1:数据流
- 原始 HTML 标签(如 `<img alt="..." />`、`<a title="...">`)必须保持合法:翻译
alt、title等属性值内部文本时,下列字符会破坏 HTML 结构,必须替换为安全形式(仅适用于原始 HTML 标签的属性值内部;普通 Markdown 正文、代码块、URL 不要主动转义):
| 字符 | 在属性值内的危险 | 替换为 |
|---|---|---|
" | 闭合 attr="..." | 目标语言合适的弯引号(如中文 “ ”)或 " |
' | 闭合 attr='...' | 目标语言合适的弯引号(如中文 ‘ ’)或 ' |
< | 被解析为新标签 | < |
> | 被解析为标签结束 | > |
& | 被解析为实体起始(除非已是 &xxx;) | & |
不要修改 src、href 等结构性属性的值,只翻译可见文本属性(alt、title)。
- 错误示例:
alt="爱丽丝拿着标着"喝我"的瓶子"← 内层英文"把外层 alt 撑断了 - 正确示例:
alt="爱丽丝拿着标着“喝我”的瓶子"或alt="爱丽丝拿着标着"喝我"的瓶子"
8. 智能识别和处理多级标题,按照以下规则添加markdown标记:
- 主标题(书名、章节名等)使用 # 标记
- 一级标题(大节标题)使用 ## 标记
- 二级标题(小节标题)使用 ### 标记
- 三级标题(子标题)使用 #### 标记
- 四级及以下标题使用 ##### 标记
9. 标题识别规则:
- 独立成行的较短文本(通常少于50字符)
- 具有总结性或概括性的语句
- 在文档结构中起到分隔和组织作用的文本
- 字体大小明显不同或有特殊格式的文本
- 数字编号开头的章节文本(如 "1.1 概述"、"第三章"等)
10. 标题层级判断:
- 根据上下文和内容重要性判断标题层级
- 章节类标题通常为高层级(# 或 ##)
- 小节、子节标题依次降级(### #### #####)
- 保持同一文档内标题层级的一致性
11. 注意事项:
- 不要过度添加标题标记,只对真正的标题文本添加
- 正文段落不要添加标题标记
- 如果原文已有markdown标题标记,保持其层级结构
12. {CUSTOM_INSTRUCTIONS if provided} 13. 术语一致性:以下术语必须严格使用指定译法,不要自行变换。表格中"原文"列或"别名"列任一形式出现在正文中时,都必须翻译为"译文"列对应的形式。
{TERM_TABLE}
邻居上下文(只读,不要翻译,不要写入输出,只用于判断代词、性别、别名和跨 chunk 指代;为空则省略):
{NEIGHBOR_CONTEXT}
markdown文件正文:
---
4.5. Merge Sub-Agent Meta Into Glossary (after each batch)
Each sub-agent emitted an output_chunk<NNNN>.meta.json alongside its translated chunk. After every batch completes, first record the completed chunk outputs in run_state.json while the glossary is still the one used for that batch, then merge observations into the canonical glossary so subsequent batches see an enriched glossary.
1. Record successfully translated chunks from this batch before mutating the glossary:
python3 {baseDir}/scripts/run_state.py record "<temp_dir>" chunk0001 chunk0002 ...If this fails, fix the missing/empty output or state error before continuing.
2. Run prepare-merge:
python3 {baseDir}/scripts/merge_meta.py prepare-merge "<temp_dir>"Capture stdout JSON. It contains four arrays:
auto_apply— new entities with no glossary collision and unanimous (target, category) across all proposing chunks.decisions_needed— items requiring main-agent judgment. Each hasid,kind, anoptionsarray, and the data needed to pick. Kinds:alias—{variant, candidate_source, evidence}. Choices:yes_alias/no_separate_entity/skip.conflict—{entity_source, field, current, proposed, evidence}. Choices:keep_current/accept_proposed/record_in_notes.new_entity_existing_alias— sub-agents proposeproposed_sourceas a new entity, but it's already someone's alias.{proposed_source, currently_alias_of, promoted_variants: [{target_proposal, category, evidence, evidence_chunks}, ...]}. Choices: oneuse_variant_Nper distinct (target, category) promotion variant (promoteproposed_sourceto standalone with that target+category, removing it from the host's aliases) /keep_as_alias/skip.existing_entity_conflict— sub-agents proposed a (target, category) forentity_sourcethat differs from the canonical. Multiple distinct differing proposals all get exposed.{entity_source, current_target, current_category, proposed_variants: [{target_proposal, category, evidence, evidence_chunks}, ...]}. Choices:keep_current/ oneuse_variant_Nper competing proposal (overwrites both target AND category, stamps the prior values into notes) /record_in_notes(canonical unchanged; every proposed variant gets logged to notes).alias_or_new_entity—varianthas multiple competing options that can't all coexist under v2's surface-form uniqueness rule. Triggered when (a)variantwas proposed both as a new standalone entity AND as an alias of one or more candidates, OR (b)variantwas proposed as an alias of two or more different candidates with no standalone competitor.{variant, alias_candidates: [{candidate_source, evidence, evidence_chunks}, ...], standalone_variants: [{target_proposal, category, evidence, evidence_chunks}, ...]}. Choices: oneuse_alias_Nper candidate (attach as alias of that candidate), oneuse_standalone_Nper competing standalone proposal (add as standalone with that target+category), orskip.conflicting_new_entity_proposals—{source, variants: [{target_proposal, category, evidence, evidence_chunks}, ...]}. Choices:use_variant_0,use_variant_1, ...,skip.consumed_chunk_ids— every meta file scanned this round (regardless of whether it produced a finding). These hashes get recorded inapplied_meta_hasheson apply.malformed_meta_chunk_ids— meta files that failed validation. Quarantined: not consumed, not crashing the run. Surface them in your batch progress.
3. If `consumed_chunk_ids` is empty → nothing was scanned; skip to Step 5.
4. If `consumed_chunk_ids` is non-empty but both `auto_apply` and `decisions_needed` are empty → still pipe {"auto_apply": [], "decisions": [], "consumed_chunk_ids": [...]} into apply-merge so the hashes get recorded. Skipping this is the bug — no-op metas would re-scan forever otherwise.
5. Otherwise, resolve each decision:
- Read its evidence quotes inline.
- Pick one option from its
optionsarray. - Build a
decisionsentry that round-trips the original decision plus your choice. The entry MUST include the originalkindand (forconflicting_new_entity_proposals) thevariantsarray, so apply-merge can validate and act:
{"id": "d1", "kind": "alias", "variant": "Taig", "candidate_source": "Tai", "choice": "yes_alias"}6. Pipe the decisions JSON into apply-merge:
echo '{"auto_apply": [...], "decisions": [...], "consumed_chunk_ids": [...]}' \
| python3 {baseDir}/scripts/merge_meta.py apply-merge "<temp_dir>"Surface the summary JSON (auto_applied, decisions_resolved, consumed_chunks, errors) in your batch progress message.
apply-merge is transactional. If any decision is malformed (wrong choice for kind, missing fields, references a non-existent entity), the entire batch aborts with a non-zero exit and stderr details — no glossary mutation, no hashes recorded. On non-zero exit, fix the offending decision and re-pipe; prepare-merge will surface the same proposals because nothing was consumed.
Decision order in the input list is not significant. apply-merge internally dispatches entity-creating decisions before alias-attaching ones, so yes_alias decisions whose candidate is created by another decision in the same batch (a use_standalone_N, use_variant_N, or promote_to_separate_entity) succeed regardless of the order you pass them in. Alias chains (e.g. Taighi → Taig where Taig → Tai is also a pending alias decision) resolve via a fixed-point loop within the alias-attacher pass — you don't need to topo-sort or sequence chained aliases manually.
On a fresh run after a previous interrupted batch, prepare-merge will pick up any meta files left behind. Don't manually delete them.
5. Verify Completeness and Retry
After all batches complete, use Glob to check that every source chunk has a corresponding output file.
If any are missing, retry them — each missing chunk as its own sub-agent. Maximum 2 attempts per chunk (initial + 1 retry).
Also read manifest.json and verify:
- Every chunk id has a corresponding output file
- No output file is empty (0 bytes)
Then run the meta-merge observability snapshot:
python3 {baseDir}/scripts/merge_meta.py status "<temp_dir>"Also run the selective re-translation state snapshot:
python3 {baseDir}/scripts/run_state.py status "<temp_dir>"Surface a one-line summary in the verification report:
Translated chunks: 50 • Meta files: 48 found / 47 consumed • Malformed: 1 (chunk0099 — see stderr) • Chunks missing meta: chunk0017, chunk0042
Severity rules (none of these fail the run — meta is non-blocking):
unmerged_meta_files > 0after Step 4.5 ran → bug, flag prominently. Resume should have caught this.malformed_meta_files > 0→ sub-agent emitted invalid meta; print chunk_ids and a "fix the file by hand and re-run if you want this chunk's feedback merged" note.meta_files_found < translated_chunks→ sub-agent-compliance issue (some chunks didn't emit meta at all). Print missing chunk_ids.
Report any chunks that failed translation after retry.
6. Translate Book Title
Read config.txt from the temp directory to get the original_title field.
Translate the title to the target language. For Chinese, wrap in 书名号: 《translated_title》.
7. Post-process — Merge and Build
Run the build script with the translated title:
python3 {baseDir}/scripts/merge_and_build.py --temp-dir "<temp_dir>" --title "<translated_title>" --cleanupIf the user provided epub_cover, add --cover "<epub_cover>". If the user provided export_name, add --export-name "<export_name>".
The --cleanup flag removes intermediate files (chunks, input.html, etc.) after a fully successful build. If the user asked to keep intermediates, omit --cleanup.
The script reads output_lang from config.txt automatically. Optional overrides: --lang, --author.
This produces in the temp directory:
output.md— merged translated markdownbook.html— web version with floating TOCbook_doc.html— ebook versionbook.docx,book.epub,book.pdf— format conversions (requires Calibre)
8. Report Results
Tell the user:
- Where the output files are located
- How many chunks were translated
- The translated title
- List generated output files with sizes
- Any format generation failures
Release version $1 by running these three commands in order. Stop and report immediately if any step fails — do not attempt to recover automatically.
git push origin main
git tag v$1 && git push --tags
npx clawhub@latest publish ./ --version $1$1 is bare semver (e.g. 0.3.0). The v prefix is applied only to the git tag, not to the ClawHub version.
First-time ClawHub publish on a machine requires npx clawhub@latest login (browser auth, cached per machine). If step 3 fails with Not logged in, ask the user to run that login command, then retry only step 3.
If step 3 fails for any other reason after the tag is already pushed, fix the cause and re-run only step 3 with the same version. Do not force-overwrite the tag (git tag -f) without explicit user approval.
# .github/FUNDING.yml
github: deusyu
custom: ["https://deusyu.app"]
name: CI
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
test:
name: Python Checks
runs-on: ubuntu-latest
env:
PYTHONPYCACHEPREFIX: .pycache
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Verify Python sources compile
run: python -m compileall scripts tests
- name: Run unit tests
run: python -m unittest discover -s tests -p 'test_*.py' -v
# Translation temp directories
*_temp/
# Full-pipeline test artifacts
tests/.artifacts/*
!tests/.artifacts/.gitkeep
!tests/.artifacts/README.md
# OS files
.DS_Store
# Python
__pycache__/
*.pyc
# Calibre intermediates
*.htmlz
# Local AI agent / tooling configs and caches
.agents/
.claude/*
!.claude/commands/
_bmad/
AGENTS.md
Project
translate-book is a Codex Skill that translates books (PDF/DOCX/EPUB) into any language using parallel subagents. Published on ClawHub as translate-book and on GitHub as deusyu/translate-book.
Structure
SKILL.md— Skill definition, the orchestration logic that Codex / OpenClaw followsscripts/convert.py— PDF/DOCX/EPUB → Markdown chunks (via Calibre HTMLZ)scripts/manifest.py— SHA-256 chunk tracking and merge validationscripts/glossary.py— Term-consistency glossary; per-chunk term tables injected into sub-agent promptsscripts/chunk_context.py— Read-only previous/next chunk excerpts injected into sub-agent promptsscripts/meta.py— Per-chunk sub-agent observation file schemascripts/merge_meta.py— Batch-boundary merge of sub-agent observations into the canonical glossaryscripts/run_state.py— Selective re-translation planner and run_state.json recorderscripts/merge_and_build.py— Merge translated chunks → HTML/DOCX/EPUB/PDFscripts/calibre_html_publish.py— Calibre format conversion wrapperscripts/template.html,scripts/template_ebook.html— HTML templates
Testing changes
Use a small file for quick checks, or the checked-in baseline book for the repository's full-pipeline test.
Quick smoke test:
python3 scripts/convert.py /path/to/small.pdf --olang zh
# then run translation via the skill
python3 scripts/merge_and_build.py --temp-dir <name>_temp --title "test"Full baseline test:
mkdir -p tests/.artifacts
cd tests/.artifacts
python3 ../../scripts/convert.py ../baselines/standard-alice/standard-alice.epub --olang zh
# then run translation via the skill
python3 ../../scripts/merge_and_build.py --temp-dir standard-alice_temp --title "test"Verify: all output_chunk*.md files exist, manifest validation passes, output formats generate.
Conventions
- Only
chunk*.mdnaming — nopage*legacy support - SKILL.md frontmatter must stay single-line per field (OpenClaw parser requirement)
- Script paths in SKILL.md use
{baseDir}not hardcoded paths - Subagent instructions in SKILL.md must be platform-neutral (work on Codex, OpenClaw, Codex)
- Checked-in baseline inputs live under
tests/baselines/<book-id>/; generated full-pipeline outputs live undertests/.artifacts/ - README changes must be synced to both README.md and README.zh-CN.md
- Releases follow
.claude/commands/release.md— three commands in order:git push origin main,git tag vX.Y.Z && git push --tags,npx clawhub@latest publish ./ --version X.Y.Z. Do not skip the git tag; it's the only version anchor in the repo
Do not
- Do not reintroduce
page*file support — it was intentionally removed - Do not hardcode
~/.Codex/skills/paths in SKILL.md — use{baseDir} - Do not put platform-specific tool names (Agent, sessions_spawn) in
allowed-toolsas the only option — keep the whitelist cross-platform - Do not add mtime-based incremental rebuild for HTML/format generation — the current skip logic is intentionally simple (existence check). Metadata/template changes require manual cleanup. This is documented in the README.
Cursor Cloud specific instructions
Environment
- Python 3.12+ is pre-installed; no version manager needed.
- System dependencies (Calibre, Pandoc) and pip packages (pypandoc, beautifulsoup4) are installed by the update script.
- Unit tests only依赖 Python stdlib(不需要 pip 包或外部二进制,直接
python3 -m unittest discover即可运行)。
Running tests
- Unit tests (CI-equivalent):
python3 -m unittest discover -s tests -p 'test_*.py' -v— runs from repo root, no setup needed. - Compile check:
python3 -m compileall scripts tests
Full pipeline integration test
Run from tests/.artifacts/ to keep generated files out of the repo root:
mkdir -p tests/.artifacts && cd tests/.artifacts
python3 ../../scripts/convert.py ../baselines/standard-alice/standard-alice.epub --olang zh
# Create mock output_chunk*.md files (copy source chunks) since actual translation requires LLM subagents
for f in standard-alice_temp/chunk*.md; do cp "$f" "standard-alice_temp/output_$(basename $f)"; done
python3 ../../scripts/merge_and_build.py --temp-dir standard-alice_temp --title "test"Known issues
- Ubuntu's Calibre 7.6.0 package has an EPUB generation bug (bytes/str mismatch in
container.py). DOCX and PDF generation work fine. This is a distro packaging issue, not a codebase bug. pypandocinstalls its CLI script to~/.local/binwhich may not be on PATH, but the Python library import works regardless.
CLAUDE.md
Project
translate-book is a Claude Code Skill that translates books (PDF/DOCX/EPUB) into any language using parallel subagents. Published on ClawHub as translate-book and on GitHub as deusyu/translate-book.
Structure
SKILL.md— Skill definition, the orchestration logic that Claude Code / OpenClaw followsscripts/convert.py— PDF/DOCX/EPUB → Markdown chunks (via Calibre HTMLZ)scripts/manifest.py— SHA-256 chunk tracking and merge validationscripts/glossary.py— Term-consistency glossary; per-chunk term tables injected into sub-agent promptsscripts/chunk_context.py— Read-only previous/next chunk excerpts injected into sub-agent promptsscripts/meta.py— Per-chunk sub-agent observation file schemascripts/merge_meta.py— Batch-boundary merge of sub-agent observations into the canonical glossaryscripts/run_state.py— Selective re-translation planner and run_state.json recorderscripts/merge_and_build.py— Merge translated chunks → HTML/DOCX/EPUB/PDFscripts/calibre_html_publish.py— Calibre format conversion wrapperscripts/template.html,scripts/template_ebook.html— HTML templates
Testing changes
Test with a small PDF to verify the full pipeline:
python3 scripts/convert.py /path/to/small.pdf --olang zh
# then run translation via the skill
python3 scripts/merge_and_build.py --temp-dir <name>_temp --title "test"Verify: all output_chunk*.md files exist, manifest validation passes, output formats generate.
Conventions
- Only
chunk*.mdnaming — nopage*legacy support - Pipeline output artifacts use the canonical names
book.html,book_doc.html,book.docx,book.epub,book.pdf. Internal scripts and skip/cache logic depend on these names; if title-based filenames are added later they must be optional aliases/copies, not silent replacements - SKILL.md frontmatter must stay single-line per field (OpenClaw parser requirement)
- Script paths in SKILL.md use
{baseDir}not hardcoded paths - Subagent instructions in SKILL.md must be platform-neutral (work on Claude Code, OpenClaw, Codex)
- README changes must be synced to both README.md and README.zh-CN.md
- Releases follow
.claude/commands/release.md— three commands in order:git push origin main,git tag vX.Y.Z && git push --tags,npx clawhub@latest publish ./ --version X.Y.Z. Do not skip the git tag; it's the only version anchor in the repo
Do not
- Do not reintroduce
page*file support — it was intentionally removed - Do not hardcode
~/.claude/skills/paths in SKILL.md — use{baseDir} - Do not put platform-specific tool names (Agent, sessions_spawn) in
allowed-toolsas the only option — keep the whitelist cross-platform - Do not add mtime-based incremental rebuild for HTML/format generation — the current skip logic is intentionally simple (existence check). Metadata/template changes require manual cleanup. This is documented in the README.
MIT License
Copyright (c) 2025 Rainman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Rainman Translate Book
English | 中文
Claude Code skill that translates entire books (PDF/DOCX/EPUB) into any language using parallel subagents.
Inspired by claude_translater. The original project uses shell scripts as its entry point, coordinating the Claude CLI with multiple step scripts to perform chunked translation. This project restructures the workflow as a Claude Code Skill, using subagents to translate chunks in parallel, with manifest-driven integrity checks, resumable runs, and multi-format output unified into a single pipeline. As the project structure and implementation differ significantly from the original, this is an independent project rather than a fork.
---
How It Works
Input (PDF/DOCX/EPUB)
│
▼
Calibre ebook-convert → HTMLZ → HTML → Markdown
│
▼
Split into chunks (chunk0001.md, chunk0002.md, ...)
│ manifest.json tracks chunk hashes
▼
Parallel subagents (8 concurrent by default)
│ each subagent: read 1 chunk → translate → write output_chunk*.md
│ batched to respect API rate limits
▼
Validate (manifest hash check, 1:1 source↔output match)
│
▼
Merge → Pandoc → HTML (with TOC) → Calibre → DOCX / EPUB / PDFEach chunk gets its own independent subagent with a fresh context window. This prevents context accumulation and output truncation that happen when translating a full book in a single session.
Features
- Parallel subagents — 8 concurrent translators per batch, each with isolated context
- Resumable + selective re-translation — chunk-level resume, with
run_state.jsontracking glossary-sensitive re-translation - Neighbor context — each chunk can see short read-only excerpts from adjacent chunks for pronoun and entity resolution
- Manifest validation — SHA-256 hash tracking prevents stale or corrupt outputs from being merged
- Multi-format output — HTML (with floating TOC), DOCX, EPUB, PDF
- Optional output controls — explicit EPUB cover, custom temp root, and user-facing export aliases
- Multi-language — zh, en, ja, ko, fr, de, es (extensible)
- PDF/DOCX/EPUB input — Calibre handles the conversion heavy lifting
Prerequisites
- Claude Code CLI — installed and authenticated
- Calibre —
ebook-convertcommand must be available (download) - Pandoc — for HTML↔Markdown conversion (download)
- Python 3 with:
pypandoc— required (pip install pypandoc)beautifulsoup4— optional, for better TOC generation (pip install beautifulsoup4)
Quick Start
1. Install the skill
Option A: npx (recommended)
npx skills add deusyu/translate-book -a claude-code -gOption B: ClawHub
clawhub install translate-bookOption C: Git clone
git clone https://github.com/deusyu/translate-book.git ~/.claude/skills/translate-book2. Translate a book
In Claude Code, say:
translate /path/to/book.pdf to ChineseOr use the slash command:
/translate-book translate /path/to/book.pdf to JapaneseThe skill handles the full pipeline automatically — convert, chunk, translate in parallel, validate, merge, and build all output formats.
3. Find your outputs
All files are in {book_name}_temp/:
| File | Description |
|---|---|
output.md | Merged translated Markdown |
book.html | Web version with floating TOC |
book.docx | Word document |
book.epub | E-book |
book.pdf | Print-ready PDF |
Repository Test Assets
- Checked-in baseline inputs live under
tests/baselines/<book-id>/. - Generated full-pipeline outputs live under
tests/.artifacts/and should not be committed. - Because
scripts/convert.pywrites{book_name}_temp/under the current working directory, run repository baseline tests from insidetests/.artifacts/to keep generated files out of the repo root.
Full-Pipeline Baseline Example
mkdir -p tests/.artifacts
cd tests/.artifacts
python3 ../../scripts/convert.py ../baselines/standard-alice/standard-alice.epub --olang zh
# then run translation via the skill
python3 ../../scripts/merge_and_build.py --temp-dir standard-alice_temp --title "test"Feedback and Contributions
Please open a detailed GitHub issue instead of starting with a pull request. This project is maintained as an AI-assisted skill pipeline, and changes need to be evaluated against the current orchestration rules, chunk/manifest contracts, baseline assets, and release flow in one maintainer-owned context.
Pull requests are not the preferred contribution path and may be closed in favor of an issue. If you already have a patch, include the idea, key diff, failing case, or verification notes in the issue; the maintainer may rework or split the implementation before merging.
A useful issue should include:
- Current behavior and expected behavior
- Input format and environment, such as PDF/DOCX/EPUB, OS, Python, Calibre, and Pandoc versions
- Minimal reproduction steps or a small public-domain sample when possible
- Logs, screenshots, or generated file names that show the failure
Pipeline Details
Step 1: Convert
python3 scripts/convert.py /path/to/book.pdf --olang zhCalibre converts the input to HTMLZ, which is extracted and converted to Markdown, then split into chunks (~6000 chars each). A manifest.json records the SHA-256 hash of each source chunk for later validation.
By default the working directory is {book_name}_temp/ under the current directory. Use --temp-root /path/to/work to keep the same leaf directory name under a different parent.
Step 1.5: Glossary (term consistency across chunks)
Each chunk is translated by a fresh-context sub-agent, which means the same proper noun can drift across multiple translations on a 100-chunk book. To fix this, the skill builds a glossary before translation:
1. Sample 5 chunks (first, last, 3 evenly-spaced middle). 2. Extract proper nouns and recurring domain terms; pick canonical translations. 3. Write <temp_dir>/glossary.json (hand-editable schema below). 4. Run python3 scripts/glossary.py count-frequencies <temp_dir> to populate per-term frequencies (ASCII terms use word-boundary regex so cat doesn't match category; CJK terms use substring; single-CJK-char terms are rejected; aliases count toward the term they belong to). 5. For each chunk, the orchestrator calls python3 scripts/glossary.py print-terms-for-chunk <temp_dir> chunkNNNN.md and injects the resulting 3-column (原文 | 别名 | 译文) markdown table into that chunk's prompt as a hard constraint. Term selection = (terms whose source OR any alias appears in this chunk) ∪ (top-N most-frequent book-wide).
{
"version": 2,
"terms": [
{"id": "Manhattan", "source": "Manhattan", "target": "曼哈顿",
"category": "place", "aliases": [], "gender": "unknown",
"confidence": "medium", "frequency": 12,
"evidence_refs": [], "notes": ""}
],
"high_frequency_top_n": 20,
"applied_meta_hashes": {}
}Existing v1 glossary.json files are auto-upgraded to v2 on first load. v2 forbids the same surface form (source or alias) appearing in two different terms; if a v1 file has polysemous duplicate sources, the upgrade aborts with a disambiguation message — fix the file by hand and reload.
Edit glossary.json between runs to fix translations; existing glossary.json is never overwritten — delete it to rebuild from scratch. scripts/run_state.py records which glossary terms each chunk used, so later glossary changes only re-translate affected chunks after the state has been recorded.
Step 2: Translate (parallel subagents)
The skill launches subagents in batches (default: 8 concurrent). Each subagent:
1. Reads one source chunk (e.g. chunk0042.md) 2. Translates to the target language 3. Uses a per-chunk term table and short read-only previous/next excerpts 4. Writes the result to output_chunk0042.md 5. Writes output_chunk0042.meta.json observations for glossary feedback
Before launching subagents, scripts/run_state.py plan <temp_dir> decides which chunks need translation, which existing outputs only need state recording, and which are unchanged. Use --retranslate-untracked only when adopting an old temp dir whose existing outputs should be forced through the current glossary. If a run is interrupted, re-running skips chunks that already have valid output files and current state. Failed chunks are retried once automatically.
Step 3: Merge & Build
python3 scripts/merge_and_build.py --temp-dir book_temp --title "《translated title》"Optional output flags:
python3 scripts/merge_and_build.py --temp-dir book_temp --title "《translated title》" --cover cover.jpg --export-name "translated-title"--cover passes an explicit image to the EPUB Calibre step. --export-name creates alias copies such as translated-title.epub while preserving the canonical book.* pipeline artifacts.
Before merging, the script validates:
- Every source chunk has a corresponding output file (1:1 match)
- Source chunk hashes match the manifest (no stale outputs)
- No output files are empty
Then: merge → Pandoc HTML → inject TOC → Calibre generates DOCX, EPUB, PDF.
Note: {book_name}_temp/ is a working directory for a single translation run. If you change the title, author, output language, template, or image assets, either use a fresh temp directory or delete the existing final artifacts (output.md, book*.html, book.docx, book.epub, book.pdf) before re-running.
Project Structure
| File | Purpose |
|---|---|
SKILL.md | Claude Code skill definition — orchestrates the full pipeline |
scripts/convert.py | PDF/DOCX/EPUB → Markdown chunks via Calibre HTMLZ |
scripts/manifest.py | Chunk manifest: SHA-256 tracking and merge validation |
scripts/glossary.py | Glossary management: per-chunk term tables for consistent terminology |
scripts/chunk_context.py | Read-only previous/next chunk excerpts for sub-agent prompts |
scripts/meta.py | Per-chunk sub-agent observation file schema (output_chunkNNNN.meta.json) |
scripts/merge_meta.py | Batch-boundary merge: sub-agent observations → canonical glossary |
scripts/run_state.py | Selective re-translation planner and run_state.json recorder |
scripts/merge_and_build.py | Merge chunks → HTML → DOCX/EPUB/PDF |
scripts/calibre_html_publish.py | Calibre wrapper for format conversion |
scripts/template.html | Web HTML template with floating TOC |
scripts/template_ebook.html | Ebook HTML template |
tests/baselines/ | Checked-in baseline book inputs for full-pipeline testing |
tests/.artifacts/ | Ignored full-pipeline test outputs |
Troubleshooting
| Problem | Solution |
|---|---|
Calibre ebook-convert not found | Install Calibre and ensure ebook-convert is in PATH |
Manifest validation failed | Source chunks changed since splitting — re-run convert.py |
Missing source chunk | Source file deleted — re-run convert.py to regenerate |
| Incomplete translation | Re-run the skill — it resumes from where it stopped |
| Changed title/template/assets but output didn't update | Delete existing output.md, book*.html, book.docx, book.epub, book.pdf from the temp dir, then re-run merge_and_build.py |
| Want page-number footers stripped from PDF output | By default, monotonic page-number sequences (e.g. 1, 2, 3, ...) are auto-detected and dropped while outliers like years (1984), chapter numbers, and citation indices stay preserved. If detection misses your case, pass --strip-page-numbers to convert.py to aggressively delete every standalone-digit line. The flag aborts if a cached input.md or chunk*.md already exists — delete them first so the flag actually takes effect. |
output.md exists but manifest invalid | Stale output — the script auto-deletes and re-merges |
Glossary upgrade rejected: duplicate source | v2 disallows two terms sharing a source/alias surface form. Edit glossary.json to disambiguate (e.g., rename one source from Apple to Apple (Inc.)) and reload. |
| PDF generation fails | Ensure Calibre is installed with PDF output support |
Roadmap
Tracking issue #7 — name/term inconsistency and pronoun/gender errors across chunks. The pipeline now covers high-frequency entities, alias/spelling drift, adjacent-chunk pronoun context, and selective re-translation after glossary changes. Full-book organic validation remains a future quality pass. The plan is four independently shippable phases.
Design principles
- Scripts do bookkeeping; LLMs do semantic merge. State, schemas, dedup, hashing, IO are deterministic Python. Naming, gender attribution, alias judgment, conflict resolution are LLM calls.
- Single writer for shared state. Only the main agent writes
glossary.jsonandrun_state.json; sub-agents write per-chunk meta files. No locking needed. - Conservative merge. New entities require evidence; alias merges need LLM judgment, not just string similarity; gender starts at
unknownand only moves up under explicit evidence; canonical values aren't silently overwritten on conflict. - Three-layer state, three separate files.
glossary.json(canonical, sub-agents read),output_chunkNNNN.meta.json(raw per-chunk observations),run_state.json(orchestration).
Phase 1 — Sub-agent feedback + glossary merge (shipped)
Closes the read+write loop. Glossary v2 adds id, aliases, gender, confidence, evidence_refs, notes (v1 files auto-upgrade on first load; the term table is now 3-col and aliases participate in selection). Sub-agents emit output_chunkNNNN.meta.json alongside each translated chunk. scripts/merge_meta.py (prepare-merge / apply-merge / status) merges per-batch with conservative rules: surface-form uniqueness enforced, malformed metas quarantined (warn + skip + count), confidence escalation via both evidence_chunks and used_term_sources, FIFO-cap at 5. See SKILL.md Step 4 / Step 4.5 / Step 5.
Phase 2 — Neighbor context for pronouns (shipped)
scripts/chunk_context.py injects prev_excerpt (last ~300 chars of previous chunk) and next_excerpt (first ~300 chars of next chunk) into each sub-agent prompt as read-only context. No new state files are introduced.
Phase 3 — Selective re-translation (shipped)
Phase 1's batch feedback only improves forward. Selective rerun closes the backward loop with scripts/run_state.py and run_state.json: per-chunk tracking of glossary_version_used, entity_ids_used, output_hash, source hash, and selected entity hashes; five planning rules cover missing/empty output, manifest source drift, untracked outputs, source drift since record, and glossary term selection/hash changes.
Phase 4 — Bootstrap warm-up (experimental, gated on Phase 1 data)
Phase 1 grows the glossary batch-by-batch, so the first batch sees the smallest glossary and has the highest drift risk. Possible approaches: sequential bootstrap, variable concurrency, or skip entirely. Decision belongs to whoever has run the system on real books.
Phase 4 remains gated on real-book evidence. The shipped schemas can still evolve under compatibility-aware migrations if production runs expose gaps.
Parallel track — Pipeline / UX backlog (partly shipped, separate from issue #7)
Recent PR discussions also surfaced several useful workflow improvements, but these are broader than one-off patches and touch repo contracts (artifact names, temp-dir behavior, cleanup semantics, or EPUB compatibility scope). Current status:
- Explicit EPUB cover support (shipped).
merge_and_build.py --cover <image>passes the image through the HTML -> EPUB Calibre step.--cover-from <epub>/ EPUB cover auto-extraction remains out of scope until the project is ready to own EPUB parsing compatibility across different package layouts. (context: closed #3) - Configurable temp workspace location (shipped).
convert.py --temp-root <dir>keeps the default cwd-local{book_name}_temp/behavior unless explicitly overridden. (context: closed #4) - Safer Calibre/Pandoc artifact cleanup (partly shipped). Page-number and Calibre-marker cleanup is regression-tested, preserving years, chapter numbers, and non-monotonic standalone numbers. Continue improving cleanup incrementally under tests. (context: closed #5)
- Optional user-facing export names (shipped).
merge_and_build.py --export-name <stem>creates alias copies while preserving canonical pipeline artifacts asbook.html,book_doc.html,book.docx,book.epub, andbook.pdf. (context: closed #6)
Star History
If you find this project helpful, please consider giving it a Star ⭐!

Sponsor
If this project saves you time, consider sponsoring to keep it maintained and improved.

License
MIT
Rainman Translate Book
English | 中文
Claude Code Skill,使用并行 subagent 将整本书(PDF/DOCX/EPUB)翻译成任意语言。
本项目受 claude_translater 启发。原项目以 shell 脚本为入口,配合 Claude CLI 和多个步骤脚本完成分块翻译;本项目则将流程重构为 Claude Code Skill,使用 subagent 按 chunk 并行翻译,并引入 manifest 驱动的完整性校验,将续跑和多格式输出整合为更统一的流水线。由于项目结构和实现方式均与原项目不同,本项目为独立实现,而非 fork。
---
工作原理
输入文件 (PDF/DOCX/EPUB)
│
▼
Calibre ebook-convert → HTMLZ → HTML → Markdown
│
▼
拆分为 chunk(chunk0001.md, chunk0002.md, ...)
│ manifest.json 记录每个 chunk 的 SHA-256 hash
▼
并行 subagent 翻译(默认 8 路并发)
│ 每个 subagent:读取 1 个 chunk → 翻译 → 写入 output_chunk*.md
│ 分批执行,控制 API 速率
▼
校验(manifest hash 比对,源文件↔输出文件 1:1 匹配)
│
▼
合并 → Pandoc → HTML(含目录)→ Calibre → DOCX / EPUB / PDF每个 chunk 由独立的 subagent 翻译,拥有全新的上下文窗口。这避免了单次会话翻译整本书时的上下文堆积和输出截断问题。
功能特性
- 并行 subagent — 每批 8 个并发翻译器,各自独立上下文
- 可续跑 + 精确重译 — chunk 级续跑,并用
run_state.json追踪受术语表影响的重译范围 - 邻居上下文 — 每个 chunk 可读取相邻 chunk 的短只读摘录,用于代词和实体判断
- Manifest 校验 — SHA-256 hash 追踪,防止过时或损坏的输出被合并
- 多格式输出 — HTML(含浮动目录)、DOCX、EPUB、PDF
- 可选输出控制 — 显式 EPUB 封面、自定义 temp root、面向用户的导出别名
- 多语言 — zh、en、ja、ko、fr、de、es(可扩展)
- 多格式输入 — PDF/DOCX/EPUB,Calibre 负责格式转换
前置要求
- Claude Code CLI — 已安装并完成认证
- Calibre —
ebook-convert命令可用(下载) - Pandoc — 用于 HTML↔Markdown 转换(下载)
- Python 3,需要:
pypandoc— 必需(pip install pypandoc)beautifulsoup4— 可选,用于更好的目录生成(pip install beautifulsoup4)
快速开始
1. 安装 Skill
方式 A:npx(推荐)
npx skills add deusyu/translate-book -a claude-code -g方式 B:ClawHub
clawhub install translate-book方式 C:Git 克隆
git clone https://github.com/deusyu/translate-book.git ~/.claude/skills/translate-book2. 翻译一本书
在 Claude Code 中直接说:
translate /path/to/book.pdf to Chinese或使用斜杠命令:
/translate-book translate /path/to/book.pdf to JapaneseSkill 自动处理完整流程 — 转换、拆分、并行翻译、校验、合并、生成所有输出格式。
3. 查看输出
所有文件在 {book_name}_temp/ 目录下:
| 文件 | 说明 |
|---|---|
output.md | 合并后的翻译 Markdown |
book.html | 网页版,含浮动目录 |
book.docx | Word 文档 |
book.epub | 电子书 |
book.pdf | 可打印 PDF |
仓库测试资产
- 需要纳入仓库的基准书输入,统一放在
tests/baselines/<book-id>/。 - 完整流水线跑出来的产物统一放在
tests/.artifacts/,不提交到版本库。 - 由于
scripts/convert.py会把{book_name}_temp/写到当前工作目录下,仓库内的 baseline 测试应从tests/.artifacts/目录里启动,这样生成文件不会散落到仓库根目录。
完整基准测试示例
mkdir -p tests/.artifacts
cd tests/.artifacts
python3 ../../scripts/convert.py ../baselines/standard-alice/standard-alice.epub --olang zh
# 然后通过 skill 完成翻译
python3 ../../scripts/merge_and_build.py --temp-dir standard-alice_temp --title "test"反馈与贡献
请优先提交详细的 GitHub issue,而不是直接从 pull request 开始。本项目按 AI 辅助的 skill pipeline 维护,任何变更都需要放在同一个由维护者掌握的上下文里,结合当前编排规则、chunk/manifest 契约、baseline 资产和发布流程一起评估。
Pull request 不是首选贡献入口,可能会被关闭并转为 issue 继续讨论。如果你已经有 patch,可以把思路、关键 diff、失败用例或验证结果写进 issue;维护者可能会据此重写或拆分实现,再决定是否合入。
一个有用的 issue 应包含:
- 当前行为与期望行为
- 输入格式和运行环境,例如 PDF/DOCX/EPUB、操作系统、Python、Calibre、Pandoc 版本
- 尽量小的复现步骤,或可公开使用的小样本文件
- 能说明问题的日志、截图或生成文件名
流程详解
第一步:转换
python3 scripts/convert.py /path/to/book.pdf --olang zhCalibre 将输入文件转为 HTMLZ,解压后转为 Markdown,再拆分为 chunk(每个约 6000 字符)。manifest.json 记录每个源 chunk 的 SHA-256 hash,用于后续校验。
默认工作目录是当前目录下的 {book_name}_temp/。如果要换父目录,可使用 --temp-root /path/to/work;叶子目录名仍保持 {book_name}_temp/。
第一步半:术语表(保证全书译名一致)
每个 chunk 由独立的 fresh-context subagent 翻译 — 这意味着同一个专有名词在 100 个 chunk 之间可能出现多种译法。为此,skill 在翻译前会先构建术语表:
1. 抽样 5 个 chunk(首章、末章、3 个均匀分布的中间章节)。 2. 提取专有名词和反复出现的领域术语,给每个术语确定一个标准译法。 3. 写入 <temp_dir>/glossary.json(schema 见下,可手动编辑)。 4. 运行 python3 scripts/glossary.py count-frequencies <temp_dir>,统计每个术语在全书的出现次数(ASCII 术语用单词边界正则,避免 cat 误匹配 category;中日韩术语用子串匹配;单字汉字术语会被拒绝以防过度匹配;别名也计入所属术语的频次)。 5. 翻译每个 chunk 之前,主 agent 调用 python3 scripts/glossary.py print-terms-for-chunk <temp_dir> chunkNNNN.md,将输出的 3 列(原文 | 别名 | 译文)markdown 表格作为硬性约束注入到该 chunk 的 prompt。术语选取 = (本 chunk 中出现原文或任一别名的术语) ∪ (全书出现频率 top-N 的术语)。
{
"version": 2,
"terms": [
{"id": "Manhattan", "source": "Manhattan", "target": "曼哈顿",
"category": "place", "aliases": [], "gender": "unknown",
"confidence": "medium", "frequency": 12,
"evidence_refs": [], "notes": ""}
],
"high_frequency_top_n": 20,
"applied_meta_hashes": {}
}已有的 v1 glossary.json 会在首次加载时自动升级为 v2。v2 禁止同一个表面词(原文或别名)同时归属于两个不同术语;如果 v1 文件存在同名(polysemous)的重复 source,升级会终止并给出消歧提示 — 手工修复后重新加载即可。
可在两次运行之间编辑 glossary.json 修正译法。已存在的 glossary.json 不会被覆盖 — 删除它才会重建。scripts/run_state.py 会记录每个 chunk 用到的术语表状态,因此后续术语表变化只会重译受影响的 chunk(前提是该 chunk 已写入 run_state)。
第二步:翻译(并行 subagent)
Skill 分批启动 subagent(默认 8 路并发)。每个 subagent:
1. 读取一个源 chunk(如 chunk0042.md) 2. 翻译为目标语言 3. 使用该 chunk 的术语表和相邻 chunk 的短只读上下文 4. 将结果写入 output_chunk0042.md 5. 写入 output_chunk0042.meta.json,供术语表反馈合并
启动 subagent 前,scripts/run_state.py plan <temp_dir> 会判断哪些 chunk 需要翻译、哪些已有输出只需记录状态、哪些无需处理。只有在接管旧 temp 目录且明确希望现有输出按当前术语表重译时,才使用 --retranslate-untracked。如果运行中断,重新运行会跳过已有合法输出且状态仍有效的 chunk。翻译失败的 chunk 会自动重试一次。
第三步:合并与构建
python3 scripts/merge_and_build.py --temp-dir book_temp --title "《译后书名》"可选输出参数:
python3 scripts/merge_and_build.py --temp-dir book_temp --title "《译后书名》" --cover cover.jpg --export-name "译后书名"--cover 会把显式封面图传给 EPUB 的 Calibre 步骤。--export-name 会额外生成如 译后书名.epub 的别名副本,同时保留内部 canonical 的 book.* 产物。
合并前校验:
- 每个源 chunk 都有对应的输出文件(1:1 匹配)
- 源 chunk hash 与 manifest 一致(无过时输出)
- 输出文件不为空
校验通过后:合并 → Pandoc 生成 HTML → 注入目录 → Calibre 生成 DOCX、EPUB、PDF。
注意: {book_name}_temp/ 是单次翻译运行的工作目录。如果修改了标题、作者、输出语言、模板或图片资源,建议使用新的 temp 目录,或先删除已有的最终产物(output.md、book*.html、book.docx、book.epub、book.pdf)再重跑。
项目结构
| 文件 | 用途 |
|---|---|
SKILL.md | Claude Code Skill 定义 — 编排完整流程 |
scripts/convert.py | PDF/DOCX/EPUB → Markdown chunks(经 Calibre HTMLZ) |
scripts/manifest.py | Chunk manifest:SHA-256 追踪与合并校验 |
scripts/glossary.py | 术语表管理:为每个 chunk 生成专属术语对照表,保证全书译名一致 |
scripts/chunk_context.py | 为 subagent prompt 提供上一/下一 chunk 的只读摘录 |
scripts/meta.py | 子 agent 单 chunk 观察文件 schema(output_chunkNNNN.meta.json) |
scripts/merge_meta.py | 批次边界合并:子 agent 观察 → canonical 术语表 |
scripts/run_state.py | 精确重译规划器和 run_state.json 记录器 |
scripts/merge_and_build.py | 合并 chunks → HTML → DOCX/EPUB/PDF |
scripts/calibre_html_publish.py | Calibre 格式转换封装 |
scripts/template.html | 网页 HTML 模板,含浮动目录 |
scripts/template_ebook.html | 电子书 HTML 模板 |
tests/baselines/ | 纳入仓库的完整链路 baseline 输入 |
tests/.artifacts/ | 被忽略的完整链路测试产物 |
常见问题
| 问题 | 解决方案 |
|---|---|
Calibre ebook-convert not found | 安装 Calibre,确保 ebook-convert 在 PATH 中 |
Manifest validation failed | 源 chunk 在拆分后被修改 — 重新运行 convert.py |
Missing source chunk | 源文件被删除 — 重新运行 convert.py 重新生成 |
| 翻译不完整 | 重新运行 Skill,会从中断处继续 |
| 修改标题、模板或图片后输出未更新 | 删除 temp 目录中的 output.md、book*.html、book.docx、book.epub、book.pdf,然后重跑 merge_and_build.py |
| 想去掉 PDF 输出中的页码 | 默认会自动识别单调递增的页码序列(如 1, 2, 3, ...)并删除,同时保留年份(1984)、章节编号、引用编号等离散的独立数字行。若识别不到你的页码格式,可给 convert.py 加 --strip-page-numbers,强制删除所有独立数字行。该标志在检测到已缓存的 input.md 或 chunk*.md 时会直接报错 — 需先删除这些缓存,标志才会生效 |
output.md exists but manifest invalid | 旧输出已过时 — 脚本会自动删除并重新合并 |
Glossary upgrade rejected: duplicate source | v2 不允许两个术语共用同一个 source/alias 表面词。手工编辑 glossary.json 消歧(例如把一个 source 从 Apple 改为 Apple (Inc.))后重新加载。 |
| PDF 生成失败 | 确认 Calibre 已安装且支持 PDF 输出 |
后续规划
跟踪 issue #7 — chunk 之间的人名/术语不一致以及代词/性别错误。当前流水线已覆盖高频实体、别名/拼写漂移、相邻 chunk 的代词上下文,以及术语表变更后的精确重译。整书自然度校验仍是后续质量阶段。整体方案分为四个可独立交付的阶段。
设计原则
- 脚本做记账,LLM 做语义合并。状态管理、schema 校验、去重、hash、IO 是确定性的 Python;命名、性别归属、别名判定、冲突解决交给 LLM。
- 共享状态单写者。
glossary.json和run_state.json仅由主 agent 写入;子 agent 只读共享状态,并写入各自的 chunk meta 文件。无需加锁。 - 保守合并。新实体必须有证据;别名合并需要 LLM 判断,不能仅靠字符串相似度;性别默认
unknown,仅在显式证据下才升级;canonical 值在冲突时不会被静默覆盖。 - 三层状态,三个独立文件。
glossary.json(canonical,子 agent 读取)、output_chunkNNNN.meta.json(子 agent 原始观察)、run_state.json(编排状态)。
Phase 1 — 子 agent 反馈 + 术语表合并(已发布)
闭合读写回路。术语表 v2 新增 id、aliases、gender、confidence、evidence_refs、notes(v1 文件首次加载时自动升级;术语表现在是 3 列,aliases 参与选词链路)。子 agent 在输出译文的同时生成 output_chunkNNNN.meta.json。新增 scripts/merge_meta.py(prepare-merge / apply-merge / status)按批次执行保守合并:跨术语 surface form 唯一性、坏 meta 隔离(warn + skip + count)、evidence_chunks 与 used_term_sources 双路 confidence 升级、FIFO 上限 5。详见 SKILL.md Step 4 / Step 4.5 / Step 5。
Phase 2 — 代词的邻居上下文(已发布)
scripts/chunk_context.py 为每个子 agent prompt 注入 prev_excerpt(上一个 chunk 末尾约 300 字)和 next_excerpt(下一个 chunk 开头约 300 字),仅作只读上下文参考。不新增状态文件。
Phase 3 — 精确重译(已发布)
Phase 1 的批次反馈只能向前优化。精确重译通过 scripts/run_state.py 和 run_state.json 闭合向后的回路:按 chunk 跟踪 glossary_version_used、entity_ids_used、output_hash、源 hash、以及选中实体的 hash;五条规划规则覆盖缺失/空输出、manifest 源文件漂移、未记录输出、记录后的源文件漂移、以及术语选择/术语 hash 变化。
Phase 4 — 冷启动预热(实验性,依赖 Phase 1 的实际数据)
Phase 1 让术语表按批次增长,因此第一批看到的术语表最小,drift 风险最高。可能的方案:顺序冷启动、可变并发、或跳过预热。决策权属于实际跑过完整书的人。
Phase 4 仍取决于真实书籍运行数据。已发布的 schema 后续如果暴露问题,也应通过兼容性迁移继续演进。
平行线路 — Pipeline / UX backlog(部分已发布,独立于 issue #7)
最近几轮 PR 讨论也暴露出一些有价值的工作流改进,但它们都不属于“一次性小补丁”:会触及仓库契约(产物命名、temp-dir 行为、清理语义、或 EPUB 兼容性边界)。当前状态:
- 显式 EPUB 封面支持(已发布)。
merge_and_build.py --cover <image>会在 HTML -> EPUB 的 Calibre 步骤透传封面图。--cover-from <epub>/ EPUB 封面自动提取仍不纳入当前范围,等项目准备好承担不同 EPUB 包布局的解析兼容性后再考虑。(context: closed #3) - 可配置的 temp 工作目录位置(已发布)。
convert.py --temp-root <dir>保留默认 cwd-local{book_name}_temp/行为,只有显式传参时才改变父目录。(context: closed #4) - 更安全的 Calibre/Pandoc 噪声清理(部分已发布)。页码和 Calibre marker 清理已有回归测试保护,保留年份、章节编号和非单调独立数字。后续清理规则继续在测试下增量增加。(context: closed #5)
- 可选的面向用户导出文件名(已发布)。
merge_and_build.py --export-name <stem>生成 alias/copy,同时流水线内部 canonical 产物仍保持book.html、book_doc.html、book.docx、book.epub、book.pdf。(context: closed #6)
Star History
如果这个项目对您有帮助,请考虑为其点亮一颗 Star ⭐!

赞助
如果这个项目帮你节省了时间,欢迎赞助支持后续维护和改进。

License
MIT
#!/usr/bin/env python3
"""
HTML Publisher using Calibre
Unified script to convert HTML to DOCX, EPUB, and PDF formats
Usage: calibre_html_publish.py input.html -o output.docx/epub/pdf
"""
import os
import sys
import subprocess
import argparse
import tempfile
import shutil
from pathlib import Path
import signal
import re
def timeout_handler(signum, frame):
"""Handle timeout signal"""
raise TimeoutError("Conversion timed out")
def find_calibre_convert():
"""Find ebook-convert command from Calibre installation"""
possible_paths = [
"/Applications/calibre.app/Contents/MacOS/ebook-convert",
"/usr/bin/ebook-convert",
"/usr/local/bin/ebook-convert",
"ebook-convert" # If in PATH
]
for path in possible_paths:
try:
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=10)
if result.returncode == 0:
print(f"✓ Found Calibre ebook-convert: {path}")
return path
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
return None
def extract_html_metadata(html_file):
"""Extract title and author from HTML file"""
try:
with open(html_file, 'r', encoding='utf-8') as f:
content = f.read()
# Extract title
title_match = re.search(r'<title[^>]*>(.*?)</title>', content, re.IGNORECASE | re.DOTALL)
if title_match:
title = re.sub(r'<[^>]+>', '', title_match.group(1)).strip()
else:
# Try h1 tag
h1_match = re.search(r'<h1[^>]*>(.*?)</h1>', content, re.IGNORECASE | re.DOTALL)
if h1_match:
title = re.sub(r'<[^>]+>', '', h1_match.group(1)).strip()
else:
title = os.path.splitext(os.path.basename(html_file))[0]
# Extract author
author_match = re.search(r'<meta[^>]*name=["\']author["\'][^>]*content=["\']([^"\']*)["\']', content, re.IGNORECASE)
if author_match:
author = author_match.group(1).strip()
else:
author = "Unknown Author"
return title, author
except Exception as e:
print(f"Warning: Could not extract metadata: {e}")
return os.path.splitext(os.path.basename(html_file))[0], "Unknown Author"
def _get_font_family_for_lang(lang):
"""Get appropriate font family CSS for the given language."""
lang_lower = lang.lower()
if lang_lower.startswith('zh'):
return '"FangSong", "FangSong_GB2312", "仿宋", "仿宋_GB2312", "STFangSong", "SimSun", serif'
elif lang_lower.startswith('ja'):
return '"Hiragino Mincho ProN", "Yu Mincho", "MS Mincho", serif'
elif lang_lower.startswith('ko'):
return '"Nanum Myeongjo", "Batang", serif'
else:
return 'Georgia, "Times New Roman", Times, serif'
def _get_pdf_font_for_lang(lang):
"""Get PDF font name for the given language."""
lang_lower = lang.lower()
if lang_lower.startswith('zh'):
return 'FangSong'
elif lang_lower.startswith('ja'):
return 'Hiragino Mincho ProN'
elif lang_lower.startswith('ko'):
return 'Nanum Myeongjo'
else:
return 'Georgia'
def prepare_html_for_conversion(input_html, temp_dir, lang="zh-CN"):
"""Prepare HTML file for conversion with font styling"""
# Create working copy
work_html = os.path.join(temp_dir, "work.html")
shutil.copy2(input_html, work_html)
font_family = _get_font_family_for_lang(lang)
try:
with open(work_html, 'r', encoding='utf-8') as f:
content = f.read()
# Add font styling CSS
font_css = f"""
<style>
body {{
font-family: {font_family};
font-size: 12pt;
line-height: 1.6;
text-decoration: none;
}}
h1, h2, h3, h4, h5, h6 {{
font-family: {font_family};
font-weight: bold;
text-decoration: none;
}}
p {{
font-family: {font_family};
text-decoration: none;
}}
a {{
text-decoration: none;
color: inherit;
}}
* {{
text-decoration: none !important;
}}
</style>
"""
# Insert CSS after <head> tag
if re.search(r'<head[^>]*>', content, re.IGNORECASE):
content = re.sub(r'(<head[^>]*>)', r'\1\n' + font_css, content, flags=re.IGNORECASE)
else:
# If no head tag, add one
if '<html' in content.lower():
content = re.sub(r'(<html[^>]*>)', r'\1\n<head>\n' + font_css + '\n</head>', content, flags=re.IGNORECASE)
else:
content = '<head>\n' + font_css + '\n</head>\n' + content
# Remove underline styling but preserve links
content = re.sub(r'text-decoration\s*:\s*underline\s*;?', '', content, flags=re.IGNORECASE)
content = re.sub(r'style\s*=\s*["\'][^"\']*text-decoration\s*:\s*underline[^"\']*["\']', '', content, flags=re.IGNORECASE)
with open(work_html, 'w', encoding='utf-8') as f:
f.write(content)
print("✓ Added font styling and removed underlines from HTML")
return work_html
except Exception as e:
print(f"Warning: Could not add font styling: {e}")
return work_html
def copy_images_if_needed(html_file, temp_dir):
"""Copy images directory if it exists alongside HTML"""
html_dir = os.path.dirname(html_file)
images_dirs = ['images', 'media', 'image', 'pics']
total_image_count = 0
# Copy all image directories found
for img_dir_name in images_dirs:
img_dir = os.path.join(html_dir, img_dir_name)
if os.path.exists(img_dir):
target_dir = os.path.join(temp_dir, img_dir_name)
try:
shutil.copytree(img_dir, target_dir, dirs_exist_ok=True)
image_count = len([f for f in os.listdir(target_dir)
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.bmp'))])
print(f"✓ Copied {image_count} images from {img_dir_name}/")
total_image_count += image_count
except Exception as e:
print(f"Warning: Could not copy {img_dir_name}/: {e}")
# Also copy any loose image files in the HTML directory
try:
for file in os.listdir(html_dir):
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.bmp')):
src_file = os.path.join(html_dir, file)
dst_file = os.path.join(temp_dir, file)
shutil.copy2(src_file, dst_file)
total_image_count += 1
print(f"✓ Copied loose image file: {file}")
except Exception as e:
print(f"Warning: Could not copy loose image files: {e}")
if total_image_count == 0:
print("ℹ No images found")
else:
print(f"✓ Total images copied: {total_image_count}")
return total_image_count
def get_output_format(output_file):
"""Determine output format from file extension"""
ext = os.path.splitext(output_file)[1].lower()
format_map = {
'.docx': 'docx',
'.epub': 'epub',
'.pdf': 'pdf'
}
return format_map.get(ext)
def convert_html_with_calibre(html_file, output_file, format_type, timeout=600, lang="zh-CN", cover=None):
"""Convert HTML to specified format using Calibre with timeout protection"""
calibre_path = find_calibre_convert()
if not calibre_path:
raise RuntimeError("Calibre ebook-convert not found. Please install Calibre.")
# Extract metadata
title, author = extract_html_metadata(html_file)
print(f"Converting HTML to {format_type.upper()} using Calibre...")
print(f"Title: {title}")
print(f"Author: {author}")
# Prepare Calibre command
cmd = [
calibre_path,
html_file,
output_file,
"--title", title,
"--authors", author,
"--language", lang,
"--book-producer", "Claude Translator",
"--preserve-cover-aspect-ratio",
"--smarten-punctuation"
]
# Add format-specific options
if format_type == 'docx':
cmd.extend([
"--disable-font-rescaling"
])
elif format_type == 'epub':
cmd.extend([
"--epub-version", "3"
])
if cover:
cmd.extend(["--cover", cover])
elif format_type == 'pdf':
pdf_font = _get_pdf_font_for_lang(lang)
cmd.extend([
"--pdf-page-numbers",
"--pdf-serif-family", pdf_font,
"--pdf-sans-family", pdf_font,
"--pdf-mono-family", pdf_font,
"--pdf-default-font-size", "12",
"--pdf-mono-font-size", "12"
])
try:
# Set up timeout signal
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(timeout)
print(f"Starting conversion (timeout: {timeout}s)...")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
# Cancel timeout
signal.alarm(0)
if result.returncode == 0:
if os.path.exists(output_file):
file_size = os.path.getsize(output_file)
print(f"✓ {format_type.upper()} conversion successful: {output_file} ({file_size} bytes)")
return True
else:
print(f"✗ {format_type.upper()} file was not created")
return False
else:
print(f"✗ Calibre conversion failed: {result.stderr}")
return False
except subprocess.TimeoutExpired:
print(f"✗ Conversion timed out after {timeout} seconds")
return False
except TimeoutError:
print(f"✗ Conversion timed out after {timeout} seconds")
return False
except Exception as e:
print(f"✗ Conversion error: {e}")
return False
finally:
# Ensure timeout is cancelled
signal.alarm(0)
def main():
"""Main function"""
parser = argparse.ArgumentParser(description='Convert HTML to DOCX/EPUB/PDF using Calibre')
parser.add_argument('input_html', help='Input HTML file')
parser.add_argument('-o', '--output', required=True, help='Output file (.docx, .epub, or .pdf)')
parser.add_argument('-t', '--timeout', type=int, default=600,
help='Conversion timeout in seconds (default: 600)')
parser.add_argument('--lang', default='zh-CN',
help='Language code for output metadata (default: zh-CN)')
parser.add_argument('--cover', default=None,
help='Cover image path for EPUB output')
args = parser.parse_args()
input_html = args.input_html
output_file = args.output
# Check input file
if not os.path.exists(input_html):
print(f"Error: Input file not found: {input_html}")
sys.exit(1)
# Determine output format
format_type = get_output_format(output_file)
if not format_type:
print(f"Error: Unsupported output format. Use .docx, .epub, or .pdf")
sys.exit(1)
if args.cover:
if format_type != 'epub':
print("Error: --cover is only supported for EPUB output")
sys.exit(1)
if not os.path.isfile(args.cover):
print(f"Error: Cover image not found: {args.cover}")
sys.exit(1)
# Always use the exact output path provided - 07_generate_formats.py already handles base_temp logic
final_output = os.path.abspath(output_file)
# Ensure output directory exists
output_dir = os.path.dirname(final_output)
os.makedirs(output_dir, exist_ok=True)
print("=== HTML Publisher (Calibre) ===")
print(f"Input: {input_html}")
print(f"Output: {final_output}")
print(f"Format: {format_type.upper()}")
print(f"Timeout: {args.timeout} seconds")
print()
try:
# Create temp directory in the same directory as input HTML
input_dir = os.path.dirname(os.path.abspath(input_html))
base_name = os.path.splitext(os.path.basename(input_html))[0]
temp_dir = os.path.join(input_dir, f"{base_name}_conversion_temp")
os.makedirs(temp_dir, exist_ok=True)
print(f"Working directory: {temp_dir}")
# Copy images if needed
image_count = copy_images_if_needed(input_html, temp_dir)
# Prepare HTML with styling
work_html = prepare_html_for_conversion(input_html, temp_dir, args.lang)
# Convert to specified format
if convert_html_with_calibre(work_html, final_output, format_type, args.timeout, args.lang, cover=args.cover):
print("\n" + "="*50)
print(f"✅ Conversion completed successfully!")
print(f"📁 File: {final_output}")
# Copy images directory to the final output directory if they exist in temp
image_count = 0
if os.path.exists(temp_dir):
output_dir = os.path.dirname(final_output)
images_dirs = ['images', 'media', 'image', 'pics']
for img_dir_name in images_dirs:
temp_img_dir = os.path.join(temp_dir, img_dir_name)
if os.path.exists(temp_img_dir):
target_img_dir = os.path.join(output_dir, img_dir_name)
try:
if os.path.exists(target_img_dir):
shutil.rmtree(target_img_dir)
shutil.copytree(temp_img_dir, target_img_dir)
img_count = len([f for f in os.listdir(target_img_dir)
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.bmp'))])
print(f"✓ Copied {img_count} images from {img_dir_name}/ directory to output location")
image_count += img_count
except Exception as e:
print(f"Warning: Could not copy {img_dir_name}/ to output: {e}")
# Also copy loose image files
try:
for file in os.listdir(temp_dir):
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.bmp')):
src_file = os.path.join(temp_dir, file)
dst_file = os.path.join(output_dir, file)
shutil.copy2(src_file, dst_file)
image_count += 1
print(f"✓ Copied loose image file: {file}")
except Exception as e:
print(f"Warning: Could not copy loose images to output: {e}")
if os.path.exists(final_output):
file_size = os.path.getsize(final_output)
print(f"💾 Size: {file_size:,} bytes")
print(f"🖼️ Images: {image_count} files")
print("🔤 Font: 仿宋体 (FangSong)")
else:
print(f"\n❌ Conversion to {format_type.upper()} failed!")
sys.exit(1)
# Clean up temp directory
try:
shutil.rmtree(temp_dir)
print(f"🧹 Cleaned up temporary directory: {temp_dir}")
except Exception as e:
print(f"Warning: Could not clean up temp directory: {e}")
except KeyboardInterrupt:
print("\nConversion interrupted by user")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
chunk_context.py - Read-only neighbor excerpts for per-chunk translation prompts.
The script never writes state. It only derives a small previous/next excerpt
from adjacent chunk*.md files so the orchestrator can inject context for
pronoun and attribute resolution without giving a sub-agent a full neighboring
chunk to translate.
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
CHUNK_RE = re.compile(r'^chunk(\d+)\.md$')
def parse_chunk_name(chunk_name):
"""Return (chunk_id, number, width) for chunkNNNN.md."""
base = os.path.basename(chunk_name)
match = CHUNK_RE.match(base)
if not match:
raise ValueError(f"Expected chunk filename like chunk0001.md, got {base!r}")
digits = match.group(1)
return f"chunk{digits}", int(digits), len(digits)
def _neighbor_path(temp_dir, number, width):
if number < 1:
return None
path = Path(temp_dir) / f"chunk{number:0{width}d}.md"
return path if path.exists() else None
def _read_excerpt(path, chars, tail=False):
if path is None:
return ""
text = path.read_text(encoding='utf-8')
excerpt = text[-chars:] if tail else text[:chars]
return excerpt.strip()
def get_neighbor_context(temp_dir, chunk_name, chars=300):
"""Return neighbor context dict for a chunk.
`prev_excerpt` is the tail of the previous source chunk; `next_excerpt` is
the head of the next source chunk. Missing neighbors produce empty strings.
"""
if chars < 0:
raise ValueError("chars must be non-negative")
chunk_id, number, width = parse_chunk_name(chunk_name)
temp_path = Path(temp_dir)
source_path = temp_path / os.path.basename(chunk_name)
if not source_path.exists():
raise FileNotFoundError(f"Source chunk not found: {source_path}")
prev_path = _neighbor_path(temp_path, number - 1, width)
next_path = _neighbor_path(temp_path, number + 1, width)
return {
"chunk_id": chunk_id,
"prev_chunk": prev_path.name if prev_path else None,
"next_chunk": next_path.name if next_path else None,
"prev_excerpt": _read_excerpt(prev_path, chars, tail=True),
"next_excerpt": _read_excerpt(next_path, chars, tail=False),
}
def format_for_prompt(context):
"""Render a concise prompt block. Empty context renders to empty string."""
parts = []
if context.get("prev_excerpt"):
parts.append(
f"Previous chunk excerpt ({context['prev_chunk']}, read-only):\n"
f"```text\n{context['prev_excerpt']}\n```"
)
if context.get("next_excerpt"):
parts.append(
f"Next chunk excerpt ({context['next_chunk']}, read-only):\n"
f"```text\n{context['next_excerpt']}\n```"
)
return "\n\n".join(parts)
def main():
parser = argparse.ArgumentParser(
description="Print read-only neighboring chunk excerpts for a translation prompt"
)
parser.add_argument("temp_dir", help="Path to <book>_temp/ directory")
parser.add_argument("chunk_file", help="Chunk filename such as chunk0001.md")
parser.add_argument(
"--chars",
type=int,
default=300,
help="Number of characters to take from each neighbor (default: 300)",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit JSON instead of a prompt-ready markdown block",
)
args = parser.parse_args()
try:
context = get_neighbor_context(args.temp_dir, args.chunk_file, args.chars)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(context, ensure_ascii=False, indent=2))
else:
block = format_for_prompt(context)
if block:
print(block)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
convert.py - Convert PDF/DOCX/EPUB to Markdown chunks via Calibre HTMLZ
Combines the original steps 1-2 into a single script.
"""
import os
import sys
import subprocess
import zipfile
import shutil
import tempfile
import argparse
import bisect
import glob
import re
from manifest import create_manifest
def find_calibre_convert():
"""Find ebook-convert command from Calibre installation"""
possible_paths = [
"/Applications/calibre.app/Contents/MacOS/ebook-convert",
"/usr/bin/ebook-convert",
"/usr/local/bin/ebook-convert",
"ebook-convert" # If in PATH
]
for path in possible_paths:
try:
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=10)
if result.returncode == 0:
print(f"Found Calibre ebook-convert: {path}")
return path
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
return None
def convert_to_htmlz(input_file, htmlz_file, calibre_path):
"""Convert input file to HTMLZ using Calibre"""
try:
print(f"Converting {input_file} to HTMLZ...")
cmd = [calibre_path, input_file, htmlz_file]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode == 0:
file_size = os.path.getsize(htmlz_file)
print(f"HTMLZ conversion successful: {htmlz_file} ({file_size} bytes)")
return True
else:
print(f"HTMLZ conversion failed: {result.stderr}")
return False
except subprocess.TimeoutExpired:
print("HTMLZ conversion timed out")
return False
except Exception as e:
print(f"HTMLZ conversion error: {e}")
return False
def extract_metadata_from_htmlz(extract_dir):
"""Extract metadata from metadata.opf file in HTMLZ"""
try:
import xml.etree.ElementTree as ET
metadata_file = None
for root, dirs, files in os.walk(extract_dir):
for file in files:
if file.lower() == 'metadata.opf':
metadata_file = os.path.join(root, file)
break
if metadata_file:
break
if not metadata_file:
return {}
tree = ET.parse(metadata_file)
root = tree.getroot()
namespaces = {
'opf': 'http://www.idpf.org/2007/opf',
'dc': 'http://purl.org/dc/elements/1.1/',
'dcterms': 'http://purl.org/dc/terms/'
}
metadata = {}
title_elem = root.find('.//dc:title', namespaces)
if title_elem is not None and title_elem.text:
metadata['title'] = title_elem.text.strip()
creator_elem = root.find('.//dc:creator', namespaces)
if creator_elem is not None and creator_elem.text:
metadata['creator'] = creator_elem.text.strip()
publisher_elem = root.find('.//dc:publisher', namespaces)
if publisher_elem is not None and publisher_elem.text:
metadata['publisher'] = publisher_elem.text.strip()
language_elem = root.find('.//dc:language', namespaces)
if language_elem is not None and language_elem.text:
metadata['language'] = language_elem.text.strip()
return metadata
except Exception as e:
print(f"Warning: Error extracting metadata: {e}")
return {}
def extract_htmlz(htmlz_file, temp_dir):
"""Extract HTMLZ file and return paths to HTML and images"""
try:
with zipfile.ZipFile(htmlz_file, 'r') as zip_file:
zip_file.extractall(temp_dir)
html_file = None
images_dir = None
for root, dirs, files in os.walk(temp_dir):
for file in files:
if file.lower() in ['index.html', 'index.htm']:
html_file = os.path.join(root, file)
break
for dir_name in dirs:
if dir_name.lower() in ['images', 'image', 'pics', 'pictures']:
images_dir = os.path.join(root, dir_name)
break
if not html_file:
for root, dirs, files in os.walk(temp_dir):
for file in files:
if file.lower().endswith(('.html', '.htm')):
html_file = os.path.join(root, file)
break
if html_file:
break
return html_file, images_dir
except Exception as e:
print(f"Error extracting HTMLZ: {e}")
return None, None
def build_temp_dir(input_file, temp_root=None):
"""Return the working directory path for an input file.
Default is the historical cwd-local {book_name}_temp/. When temp_root is
provided, only the root changes; the leaf directory name stays compatible.
"""
base_name = os.path.splitext(os.path.basename(input_file))[0]
leaf = f"{base_name}_temp"
if temp_root:
return os.path.join(temp_root, leaf)
return leaf
def setup_temp_directory(input_file, html_file, images_dir, temp_root=None):
"""Setup temp directory with HTML and images"""
try:
temp_dir = build_temp_dir(input_file, temp_root)
os.makedirs(temp_dir, exist_ok=True)
input_html = os.path.join(temp_dir, "input.html")
if os.path.exists(input_html):
print(f"Skipping HTML copy - input.html already exists")
else:
shutil.copy2(html_file, input_html)
print(f"Copied HTML to: {input_html}")
if images_dir and os.path.exists(images_dir):
target_images_dir = os.path.join(temp_dir, "images")
if os.path.exists(target_images_dir):
print(f"Skipping images copy - images directory already exists")
else:
shutil.copytree(images_dir, target_images_dir)
print(f"Copied images to: {target_images_dir}")
return temp_dir
except Exception as e:
print(f"Error setting up temp directory: {e}")
return None
def convert_html_to_markdown(html_file, md_file, strip_page_numbers=False):
"""Convert HTML to Markdown using pandoc"""
try:
import pypandoc
pypandoc.convert_file(
html_file,
'markdown',
outputfile=md_file,
extra_args=['--wrap=none']
)
if os.path.exists(md_file):
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read()
content = content.replace('\ufeff', '')
content = content.replace('\u00a0', ' ')
content = clean_calibre_markers(content, strip_page_numbers=strip_page_numbers)
with open(md_file, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Markdown conversion successful: {md_file}")
return True
else:
print("Markdown file was not created")
return False
except ImportError:
print("pypandoc not found. Install with: pip install pypandoc")
return False
except Exception as e:
print(f"HTML to Markdown conversion failed: {e}")
return False
_PAGE_SEQUENCE_MIN_LENGTH = 4
_PAGE_SEQUENCE_MIN_RATIO = 0.5
def _detect_page_number_lines(lines):
"""Detect standalone-digit lines that form a monotonic page-number sequence.
Returns a set of line indices that should be dropped as page numbers.
Algorithm: collect every standalone-digit line in document order, find the
Longest Non-Decreasing Subsequence (LNDS) of their integer values via
bisect_right with parent-pointer reconstruction. If the LNDS is long enough
and covers a large enough fraction of all standalone digits, treat those
elements as page numbers. Outliers (years like 1984, chapter numbers,
citation indices) sit off the monotonic spine and stay preserved.
"""
digit_indices = []
digit_values = []
for i, line in enumerate(lines):
s = line.strip()
if s.isdigit():
digit_indices.append(i)
digit_values.append(int(s))
n = len(digit_values)
if n < _PAGE_SEQUENCE_MIN_LENGTH:
return set()
tails = []
tails_idx = []
parents = [-1] * n
for i, v in enumerate(digit_values):
pos = bisect.bisect_right(tails, v)
if pos > 0:
parents[i] = tails_idx[pos - 1]
if pos == len(tails):
tails.append(v)
tails_idx.append(i)
else:
tails[pos] = v
tails_idx[pos] = i
lnds = []
cur = tails_idx[-1]
while cur != -1:
lnds.append(cur)
cur = parents[cur]
lnds.reverse()
if len(lnds) < _PAGE_SEQUENCE_MIN_LENGTH:
return set()
if len(lnds) / n < _PAGE_SEQUENCE_MIN_RATIO:
return set()
return {digit_indices[i] for i in lnds}
def clean_calibre_markers(content, strip_page_numbers=False):
"""Clean up Calibre-specific markers from markdown content.
Standalone digit lines are handled in two layers:
1. If a line is adjacent to Calibre noise (::: fence, .ct}/.cn} marker),
drop it — clearly leftover.
2. Otherwise, run LNDS over all standalone digits to detect a monotonic
page-number sequence and drop those. Outliers like years (1984),
chapter numbers, and citation indices stay preserved.
Pass strip_page_numbers=True to bypass both layers and aggressively delete
every standalone-digit line (legacy behavior).
"""
content = re.sub(r'\{\.calibre[^}]*\}', '', content)
content = re.sub(r'\(#calibre_link-\d+\)', '', content)
# Clean heading calibre attribute blocks: {#calibre_link-N .calibreN}
content = re.sub(r'\s*\{#calibre_link-\d+[^}]*\}', '', content)
# Clean [**text**] format to **text**
content = re.sub(r'\[\*\*([^*]+)\*\*\]', r'**\1**', content)
lines = content.split('\n')
page_number_lines = set() if strip_page_numbers else _detect_page_number_lines(lines)
def is_calibre_noise(line):
s = line.strip()
if not s:
return False
if s.startswith(':::'):
return True
if s.endswith('.ct}') or s.endswith('.cn}'):
return True
return False
def prev_nonblank(idx):
for j in range(idx - 1, -1, -1):
if lines[j].strip():
return lines[j]
return None
def next_nonblank(idx):
for j in range(idx + 1, len(lines)):
if lines[j].strip():
return lines[j]
return None
cleaned_lines = []
for i, line in enumerate(lines):
stripped_line = line.strip()
if stripped_line.startswith(':::'):
continue
if stripped_line.endswith('.ct}') or stripped_line.endswith('.cn}'):
continue
if re.match(r'^\s*\d+\s*$', line):
if strip_page_numbers:
continue
if i in page_number_lines:
continue
prev = prev_nonblank(i)
nxt = next_nonblank(i)
if (prev is not None and is_calibre_noise(prev)) or \
(nxt is not None and is_calibre_noise(nxt)):
continue
# else: preserve as real content
cleaned_lines.append(line)
content = '\n'.join(cleaned_lines)
content = re.sub(r'\n{3,}', '\n\n', content)
return content
# =============================================================================
# Structural block parsing and chunk splitting (Step 3)
# =============================================================================
def parse_structural_blocks(content):
"""Parse markdown into structural blocks that should not be split.
Returns list of (text, block_type) tuples where block_type is one of:
'heading', 'code_block', 'table', 'list', 'blockquote', 'image', 'paragraph'
"""
blocks = []
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# Code block (fenced)
if stripped.startswith('```'):
block_lines = [line]
i += 1
while i < len(lines):
block_lines.append(lines[i])
if lines[i].strip().startswith('```') and len(block_lines) > 1:
i += 1
break
i += 1
blocks.append(('\n'.join(block_lines), 'code_block'))
continue
# Heading
if re.match(r'^#{1,6}\s', stripped):
blocks.append((line, 'heading'))
i += 1
continue
# Blockquote
if stripped.startswith('>'):
block_lines = [line]
i += 1
while i < len(lines) and (lines[i].strip().startswith('>') or
(lines[i].strip() and not re.match(r'^#{1,6}\s', lines[i].strip())
and not lines[i].strip().startswith('```')
and not lines[i].strip().startswith('|')
and not re.match(r'^[-*+]\s', lines[i].strip())
and not re.match(r'^\d+\.\s', lines[i].strip())
and block_lines[-1].strip().startswith('>'))):
block_lines.append(lines[i])
i += 1
blocks.append(('\n'.join(block_lines), 'blockquote'))
continue
# Table (lines starting with |)
if stripped.startswith('|'):
block_lines = [line]
i += 1
while i < len(lines) and lines[i].strip().startswith('|'):
block_lines.append(lines[i])
i += 1
blocks.append(('\n'.join(block_lines), 'table'))
continue
# List (unordered or ordered)
if re.match(r'^[-*+]\s', stripped) or re.match(r'^\d+\.\s', stripped):
block_lines = [line]
i += 1
while i < len(lines):
s = lines[i].strip()
# Continue list: list items, indented continuation, or blank lines within list
if (re.match(r'^[-*+]\s', s) or re.match(r'^\d+\.\s', s) or
(lines[i].startswith(' ') and s) or
(s == '' and i + 1 < len(lines) and
(re.match(r'^[-*+]\s', lines[i+1].strip()) or
re.match(r'^\d+\.\s', lines[i+1].strip()) or
lines[i+1].startswith(' ')))):
block_lines.append(lines[i])
i += 1
else:
break
blocks.append(('\n'.join(block_lines), 'list'))
continue
# Image line (standalone or with surrounding caption)
if re.match(r'!\[', stripped):
blocks.append((line, 'image'))
i += 1
continue
# Empty line — just a paragraph separator
if stripped == '':
blocks.append((line, 'paragraph'))
i += 1
continue
# Regular paragraph — collect contiguous non-empty, non-special lines
block_lines = [line]
i += 1
while i < len(lines):
s = lines[i].strip()
if (s == '' or s.startswith('```') or re.match(r'^#{1,6}\s', s) or
s.startswith('>') or s.startswith('|') or
re.match(r'^[-*+]\s', s) or re.match(r'^\d+\.\s', s) or
re.match(r'!\[', s)):
break
block_lines.append(lines[i])
i += 1
blocks.append(('\n'.join(block_lines), 'paragraph'))
continue
return blocks
def merge_blocks_to_chunks(blocks, target_size=6000):
"""Merge structural blocks into chunks respecting target_size.
Prefers to split at heading boundaries. Never splits within a single
structural block unless the block itself exceeds target_size * 2.
"""
chunks = []
current_parts = []
current_size = 0
def flush():
nonlocal current_parts, current_size
if current_parts:
chunks.append('\n'.join(current_parts))
current_parts = []
current_size = 0
for text, btype in blocks:
block_size = len(text)
# If a single block is oversized, handle degradation
if block_size > target_size * 2:
flush()
print(f" WARNING: Oversized {btype} block ({block_size} chars), force-splitting")
sub_chunks = _force_split_block(text, target_size)
chunks.extend(sub_chunks)
continue
# Prefer to split at heading boundaries
if btype == 'heading' and current_size > 0:
flush()
# Would adding this block exceed target?
if current_size + block_size > target_size and current_parts:
flush()
current_parts.append(text)
current_size += block_size
flush()
return chunks
def _force_split_block(text, target_size):
"""Force-split an oversized block by paragraph (empty lines), then by lines.
For fenced code blocks, each resulting chunk gets proper opening/closing fences
so it remains valid Markdown.
"""
stripped = text.strip()
is_fenced_code = stripped.startswith('```')
# Extract fence info for code blocks
fence_opener = ''
if is_fenced_code:
first_line = stripped.split('\n', 1)[0]
fence_opener = first_line # e.g. "```python"
# Try splitting by empty lines first (not applicable for code blocks — no empty lines expected)
if not is_fenced_code:
paragraphs = re.split(r'\n\n+', text)
if len(paragraphs) > 1:
chunks = []
current = []
current_size = 0
for para in paragraphs:
para_size = len(para)
if current_size + para_size > target_size and current:
chunks.append('\n\n'.join(current))
current = [para]
current_size = para_size
else:
current.append(para)
current_size += para_size
if current:
chunks.append('\n\n'.join(current))
return chunks
# Split by lines
lines = text.split('\n')
# For code blocks, strip the opening and closing fences before splitting content
if is_fenced_code:
# Remove opening fence line
content_lines = lines[1:]
# Remove closing fence line if present
if content_lines and content_lines[-1].strip().startswith('```'):
content_lines = content_lines[:-1]
lines = content_lines
chunks = []
current = []
current_size = 0
for line in lines:
line_size = len(line) + 1
if current_size + line_size > target_size and current:
chunks.append('\n'.join(current))
current = [line]
current_size = line_size
else:
current.append(line)
current_size += line_size
if current:
chunks.append('\n'.join(current))
# Re-wrap each chunk in fences for code blocks
if is_fenced_code:
chunks = [f"{fence_opener}\n{chunk}\n```" for chunk in chunks]
return chunks
def split_markdown_structured(md_file, temp_dir, target_size=6000):
"""Split markdown into structural chunks.
Returns list of chunk filenames (e.g. ['chunk0001.md', ...]).
"""
try:
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read()
blocks = parse_structural_blocks(content)
chunk_texts = merge_blocks_to_chunks(blocks, target_size)
chunk_files = []
for i, chunk_text in enumerate(chunk_texts, 1):
filename = f"chunk{i:04d}.md"
chunk_file = os.path.join(temp_dir, filename)
with open(chunk_file, 'w', encoding='utf-8') as f:
f.write(chunk_text)
chunk_files.append(filename)
print(f"Split into {len(chunk_files)} chunks")
for filename in chunk_files:
filepath = os.path.join(temp_dir, filename)
size = os.path.getsize(filepath)
print(f" {filename}: {size} characters")
return chunk_files
except Exception as e:
print(f"Error splitting markdown: {e}")
return []
def _find_existing_chunk_files(temp_dir):
"""Find existing chunk source files (excluding output_ prefixed).
Returns (filenames_list, is_legacy=False).
"""
chunk_files = glob.glob(os.path.join(temp_dir, 'chunk*.md'))
chunk_files = [os.path.basename(f) for f in chunk_files if not os.path.basename(f).startswith('output_')]
if chunk_files:
return sorted(chunk_files), False
return [], False
def create_config_file(temp_dir, input_file, input_lang, output_lang, metadata=None):
"""Create config.txt file for the pipeline"""
try:
config_file = os.path.join(temp_dir, "config.txt")
config_content = f"""# Translation Configuration
input_file={input_file}
input_lang={input_lang}
output_lang={output_lang}
conversion_method=calibre_htmlz
"""
if metadata:
config_content += f"\n# Book Metadata\n"
if 'title' in metadata:
config_content += f"original_title={metadata['title']}\n"
if 'creator' in metadata:
config_content += f"creator={metadata['creator']}\n"
if 'publisher' in metadata:
config_content += f"publisher={metadata['publisher']}\n"
if 'language' in metadata:
config_content += f"source_language={metadata['language']}\n"
with open(config_file, 'w', encoding='utf-8') as f:
f.write(config_content)
print(f"Created config file: {config_file}")
return True
except Exception as e:
print(f"Error creating config file: {e}")
return False
def _do_split_and_manifest(temp_dir, input_md, chunk_size):
"""Split markdown and create manifest. Returns chunk count or 0 on failure."""
existing, is_legacy = _find_existing_chunk_files(temp_dir)
if existing:
print(f"Skipping markdown splitting - found {len(existing)} existing {'page' if is_legacy else 'chunk'} files")
# Create/update manifest for existing files
create_manifest(temp_dir, existing, input_md)
return len(existing)
chunk_files = split_markdown_structured(input_md, temp_dir, chunk_size)
if not chunk_files:
return 0
create_manifest(temp_dir, chunk_files, input_md)
return len(chunk_files)
def _check_strip_page_numbers_cache_conflict(strip_flag, temp_dir, input_md):
"""Return list of cached files that would silently neutralize --strip-page-numbers.
The flag only takes effect inside clean_calibre_markers, which runs during
HTML→Markdown conversion. If input.md or chunk*.md already exist from a
prior run, both are reused as-is and the flag becomes a no-op. Surface
that conflict so the user knows to clean up.
"""
if not strip_flag:
return []
if not os.path.isdir(temp_dir):
return []
blockers = []
if os.path.exists(input_md):
blockers.append(input_md)
existing_chunks = [
f for f in glob.glob(os.path.join(temp_dir, 'chunk*.md'))
if not os.path.basename(f).startswith('output_')
]
if existing_chunks:
blockers.append(f"{len(existing_chunks)} chunk file(s) under {temp_dir}/")
return blockers
def _abort_on_strip_cache_conflict(blockers, temp_dir):
if not blockers:
return
print("Error: --strip-page-numbers cannot take effect because cached files exist:")
for b in blockers:
print(f" - {b}")
print(f"Delete the cached files (or remove the entire {temp_dir}/ directory) and re-run.")
sys.exit(1)
def main():
"""Main conversion function"""
parser = argparse.ArgumentParser(description="Convert PDF/DOCX/EPUB to markdown chunks via HTMLZ")
parser.add_argument("input_file", help="Input file (PDF, DOCX, or EPUB)")
parser.add_argument("-l", "--ilang", default="auto", help="Input language (default: auto)")
parser.add_argument("--olang", default="zh", help="Output language (default: zh)")
parser.add_argument("--chunk-size", type=int, default=6000, help="Target chunk size in characters (default: 6000)")
parser.add_argument(
"--temp-root",
default=None,
help="Directory under which {book_name}_temp/ will be created (default: current working directory)",
)
parser.add_argument(
"--strip-page-numbers",
action="store_true",
help="Aggressively delete every standalone-digit line (legacy behavior). "
"Default is off: standalone digits are preserved unless adjacent to Calibre noise.",
)
args = parser.parse_args()
input_file = args.input_file
if not os.path.exists(input_file):
print(f"Error: Input file not found: {input_file}")
sys.exit(1)
file_ext = os.path.splitext(input_file)[1].lower()
if file_ext not in ['.pdf', '.docx', '.epub']:
print(f"Error: Unsupported file type: {file_ext}")
sys.exit(1)
print("=== File Conversion via Calibre HTMLZ ===")
print(f"Input file: {input_file}")
print(f"Target chunk size: {args.chunk_size} characters")
if args.temp_root:
print(f"Temp root: {args.temp_root}")
calibre_path = find_calibre_convert()
if not calibre_path:
print("Error: Calibre ebook-convert not found")
print("Please install Calibre: https://calibre-ebook.com/")
sys.exit(1)
htmlz_file = f"{os.path.splitext(input_file)[0]}.htmlz"
try:
temp_dir = build_temp_dir(input_file, args.temp_root)
input_html_path = os.path.join(temp_dir, "input.html")
if os.path.exists(input_html_path):
print(f"Skipping HTMLZ conversion - input.html already exists")
metadata = {}
config_file = os.path.join(temp_dir, "config.txt")
if os.path.exists(config_file):
try:
with open(config_file, 'r', encoding='utf-8') as f:
for line in f:
if '=' in line:
key, value = line.strip().split('=', 1)
if key == 'original_title':
metadata['title'] = value
elif key == 'creator':
metadata['creator'] = value
elif key == 'publisher':
metadata['publisher'] = value
elif key == 'source_language':
metadata['language'] = value
except Exception as e:
print(f"Warning: Could not read metadata from config: {e}")
input_md = os.path.join(temp_dir, "input.md")
_abort_on_strip_cache_conflict(
_check_strip_page_numbers_cache_conflict(args.strip_page_numbers, temp_dir, input_md),
temp_dir,
)
if os.path.exists(input_md):
print(f"Skipping HTML to Markdown conversion - input.md already exists")
else:
if not convert_html_to_markdown(input_html_path, input_md, strip_page_numbers=args.strip_page_numbers):
sys.exit(1)
chunk_count = _do_split_and_manifest(temp_dir, input_md, args.chunk_size)
if chunk_count == 0:
sys.exit(1)
create_config_file(temp_dir, input_file, args.ilang, args.olang, metadata)
print("Conversion completed successfully!")
print(f"Temp directory: {temp_dir}")
return
if not convert_to_htmlz(input_file, htmlz_file, calibre_path):
sys.exit(1)
with tempfile.TemporaryDirectory() as extract_dir:
html_file, images_dir = extract_htmlz(htmlz_file, extract_dir)
if not html_file:
sys.exit(1)
metadata = extract_metadata_from_htmlz(extract_dir)
temp_dir = setup_temp_directory(input_file, html_file, images_dir, temp_root=args.temp_root)
if not temp_dir:
sys.exit(1)
input_html = os.path.join(temp_dir, "input.html")
input_md = os.path.join(temp_dir, "input.md")
_abort_on_strip_cache_conflict(
_check_strip_page_numbers_cache_conflict(args.strip_page_numbers, temp_dir, input_md),
temp_dir,
)
if os.path.exists(input_md):
print(f"Skipping HTML to Markdown conversion - input.md already exists")
else:
if not convert_html_to_markdown(input_html, input_md, strip_page_numbers=args.strip_page_numbers):
sys.exit(1)
chunk_count = _do_split_and_manifest(temp_dir, input_md, args.chunk_size)
if chunk_count == 0:
sys.exit(1)
create_config_file(temp_dir, input_file, args.ilang, args.olang, metadata)
print("Conversion completed successfully!")
print(f"Temp directory: {temp_dir}")
print(f"Markdown chunks: {chunk_count} files")
if os.path.exists(htmlz_file):
os.remove(htmlz_file)
except KeyboardInterrupt:
print("\nConversion interrupted by user")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
manifest.py - Manifest management for chunk tracking and merge validation.
"""
import os
import json
import hashlib
def file_hash(filepath):
"""Compute SHA-256 hash of a file."""
h = hashlib.sha256()
with open(filepath, 'rb') as f:
for block in iter(lambda: f.read(8192), b''):
h.update(block)
return h.hexdigest()
def create_manifest(temp_dir, chunk_files, source_md_path):
"""Create manifest.json after splitting.
Args:
temp_dir: temp directory path
chunk_files: list of chunk filenames (e.g. ['chunk0001.md', ...])
source_md_path: path to the source input.md
"""
source_hash = file_hash(source_md_path) if os.path.exists(source_md_path) else ""
chunks = []
for order, filename in enumerate(chunk_files, 1):
filepath = os.path.join(temp_dir, filename)
# Derive output filename: chunk0001.md -> output_chunk0001.md
output_filename = f"output_{filename}"
chunk_id = os.path.splitext(filename)[0] # e.g. "chunk0001"
chunks.append({
"id": chunk_id,
"order": order,
"source_file": filename,
"source_hash": file_hash(filepath) if os.path.exists(filepath) else "",
"output_file": output_filename,
})
manifest = {
"chunk_count": len(chunks),
"source_hash": source_hash,
"chunks": chunks,
}
manifest_path = os.path.join(temp_dir, "manifest.json")
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
print(f"Created manifest.json ({len(chunks)} chunks)")
return manifest
def load_manifest(temp_dir):
"""Load manifest.json from temp_dir. Returns None if not found."""
manifest_path = os.path.join(temp_dir, "manifest.json")
if not os.path.exists(manifest_path):
return None
with open(manifest_path, 'r', encoding='utf-8') as f:
return json.load(f)
def validate_for_merge(temp_dir):
"""Validate that all chunks have been translated before merging.
Returns (ok, ordered_output_files, warnings) where:
ok: True if merge can proceed
ordered_output_files: list of output file paths in order
warnings: list of warning strings
"""
manifest = load_manifest(temp_dir)
if manifest is None:
# No manifest — fall back to legacy glob-based merge
return True, None, ["No manifest.json found, using legacy merge"]
errors = []
warnings = []
ordered_output_files = []
for chunk in sorted(manifest["chunks"], key=lambda c: c["order"]):
output_path = os.path.join(temp_dir, chunk["output_file"])
source_path = os.path.join(temp_dir, chunk["source_file"])
# Check source file exists — reject outputs without source chunks
if not os.path.exists(source_path):
errors.append(
f"Missing source: {chunk['source_file']} (chunk {chunk['id']}) — "
f"cannot verify output integrity without source chunk"
)
continue
# Check source hash matches — detect stale outputs from changed sources
if chunk.get("source_hash"):
current_hash = file_hash(source_path)
if current_hash != chunk["source_hash"]:
errors.append(
f"Source changed since splitting: {chunk['source_file']} "
f"(chunk {chunk['id']}). "
f"Expected hash {chunk['source_hash'][:12]}..., "
f"got {current_hash[:12]}... — "
f"delete output and re-translate, or re-run convert.py to re-split"
)
continue
# Check output exists
if not os.path.exists(output_path):
errors.append(f"Missing output: {chunk['output_file']} (chunk {chunk['id']})")
continue
# Check non-empty
output_size = os.path.getsize(output_path)
if output_size == 0:
errors.append(f"Empty output: {chunk['output_file']} (chunk {chunk['id']})")
continue
# Check abnormally short
if os.path.exists(source_path):
source_size = os.path.getsize(source_path)
if source_size > 0 and output_size < source_size * 0.1:
warnings.append(
f"Suspiciously short: {chunk['output_file']} "
f"({output_size} bytes vs source {source_size} bytes)"
)
ordered_output_files.append(output_path)
if errors:
for e in errors:
print(f"ERROR: {e}")
return False, None, warnings
for w in warnings:
print(f"WARNING: {w}")
return True, ordered_output_files, warnings
<!DOCTYPE html>
<html lang="$lang$">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title$</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
line-height: 1.7;
color: #2c3e50;
background-color: #ffffff;
max-width: 800px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-size: 1rem;
}
/* 标题样式 */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: #1a202c;
line-height: 1.3;
}
h1 {
font-size: 2.25rem;
border-bottom: 3px solid #3182ce;
padding-bottom: 0.75rem;
margin-top: 0;
margin-bottom: 2rem;
}
h2 {
font-size: 1.875rem;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0.5rem;
margin-top: 3rem;
}
h3 {
font-size: 1.5rem;
color: #2d3748;
}
h4 {
font-size: 1.25rem;
color: #4a5568;
}
h5 {
font-size: 1.125rem;
color: #4a5568;
}
h6 {
font-size: 1rem;
color: #718096;
font-weight: 500;
}
/* 段落样式 */
p {
margin-bottom: 1.25rem;
text-align: justify;
word-break: break-word;
}
/* 链接样式 */
a {
color: #3182ce;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: #2c5282;
text-decoration: underline;
}
/* 图片样式 - 居中显示 */
img {
max-width: 100%;
height: auto;
display: block;
margin: 2rem auto;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* 列表样式 */
ul, ol {
margin: 1.25rem 0;
padding-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}
ul ul, ol ol, ul ol, ol ul {
margin: 0.5rem 0;
}
/* 代码样式 */
code {
font-family: 'Fira Code', 'Monaco', 'Consolas', 'Ubuntu Mono', monospace;
background-color: #f7fafc;
color: #e53e3e;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.875rem;
}
pre {
background-color: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 1.25rem;
margin: 1.5rem 0;
overflow-x: auto;
font-family: 'Fira Code', 'Monaco', 'Consolas', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
pre code {
background: none;
color: inherit;
padding: 0;
border-radius: 0;
font-size: inherit;
}
/* 引用样式 */
blockquote {
border-left: 4px solid #3182ce;
margin: 1.5rem 0;
padding: 1rem 1.5rem;
background-color: #f7fafc;
color: #4a5568;
font-style: italic;
border-radius: 0 6px 6px 0;
}
blockquote p:last-child {
margin-bottom: 0;
}
/* 表格样式 */
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
background-color: #ffffff;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
th {
background-color: #f7fafc;
font-weight: 600;
color: #2d3748;
}
tr:hover {
background-color: #f7fafc;
}
/* 分隔线 */
hr {
border: none;
border-top: 2px solid #e2e8f0;
margin: 3rem 0;
}
/* 页面分隔器 */
.page-separator {
border-top: 2px solid #e2e8f0;
margin: 3rem 0;
text-align: center;
position: relative;
}
.page-separator::after {
content: "• • •";
background-color: #ffffff;
color: #a0aec0;
padding: 0 1rem;
position: relative;
top: -0.75rem;
}
/* 强调样式 */
strong, b {
font-weight: 700;
color: #1a202c;
}
em, i {
font-style: italic;
color: #4a5568;
}
/* 响应式设计 */
@media (max-width: 768px) {
body {
padding: 1rem;
font-size: 0.9rem;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
h4 {
font-size: 1.125rem;
}
table {
font-size: 0.875rem;
}
th, td {
padding: 0.5rem 0.75rem;
}
pre {
padding: 1rem;
font-size: 0.8rem;
}
blockquote {
padding: 0.75rem 1rem;
margin: 1rem 0;
}
}
@media (max-width: 480px) {
body {
padding: 0.75rem;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5rem;
}
ul, ol {
padding-left: 1.5rem;
}
}
/* 打印样式 */
@media print {
body {
max-width: none;
padding: 0;
font-size: 12pt;
line-height: 1.5;
color: #000;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
img {
max-width: 100% !important;
page-break-inside: avoid;
}
blockquote, pre {
page-break-inside: avoid;
}
a {
color: #000;
text-decoration: underline;
}
}
</style>
</head>
<body>
$body$
</body>
</html>
Artifacts
Generated full-pipeline baseline outputs belong here.
This directory is intentionally git-ignored except for this file and .gitkeep.
Run repository baseline tests from inside this directory so scripts/convert.py writes <book-name>_temp/ here instead of the repo root.
Related skills
How it compares
Pick translate-book over single-shot LLM translation when you need chunked, parallel book pipelines with pandoc-driven multi-format exports.
FAQ
Which input formats does translate-book support?
translate-book accepts PDF, DOCX, and EPUB book inputs. The pipeline converts sources to Markdown chunks, translates them with parallel sub-agents, then rebuilds HTML, DOCX, EPUB, or PDF outputs.
What binaries does translate-book require?
translate-book requires python3 and pandoc, with ebook-convert from Calibre listed as an anyBins dependency. The skill uses Bash, Read, Write, Edit, Glob, Grep, and Agent tools during orchestration.
Is Translate Book safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.