Repository: yihong0618/bilingual_book_maker
Branch: main
Commit: d66c685c24b4
Files: 60
Total size: 264.8 KB
Directory structure:
gitextract_6fkzntyz/
├── .dockerignore
├── .github/
│ └── workflows/
│ ├── docs.yaml
│ ├── make_test_ebook.yaml
│ └── release.yaml
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── README-CN.md
├── README.md
├── book_maker/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── config.py
│ ├── loader/
│ │ ├── __init__.py
│ │ ├── base_loader.py
│ │ ├── epub_loader.py
│ │ ├── helper.py
│ │ ├── md_loader.py
│ │ ├── pdf_loader.py
│ │ ├── srt_loader.py
│ │ └── txt_loader.py
│ ├── obok.py
│ ├── translator/
│ │ ├── __init__.py
│ │ ├── base_translator.py
│ │ ├── caiyun_translator.py
│ │ ├── chatgptapi_translator.py
│ │ ├── claude_translator.py
│ │ ├── custom_api_translator.py
│ │ ├── deepl_free_translator.py
│ │ ├── deepl_translator.py
│ │ ├── gemini_translator.py
│ │ ├── google_translator.py
│ │ ├── groq_translator.py
│ │ ├── litellm_translator.py
│ │ ├── qwen_translator.py
│ │ ├── tencent_transmart_translator.py
│ │ └── xai_translator.py
│ └── utils.py
├── disclaimer.md
├── docs/
│ ├── book_source.md
│ ├── cmd.md
│ ├── disclaimer.md
│ ├── env_settings.md
│ ├── index.md
│ ├── installation.md
│ ├── model_lang.md
│ ├── prompt.md
│ └── quickstart.md
├── make_book.py
├── mkdocs.yml
├── prompt_md.json
├── prompt_md.prompt.md
├── prompt_template_sample.json
├── pyproject.toml
├── tests/
│ ├── test_epub_metadata.py
│ ├── test_integration.py
│ ├── test_pdf_cli.py
│ └── test_pdf_loader.py
└── typos.toml
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
Dockerfile*
docker-compose*
LICENSE
test_books
README*
.dockerignore
.git
.github
.gitignore
.vscode
================================================
FILE: .github/workflows/docs.yaml
================================================
name: Publish docs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.10'
- run: pip install mkdocs mkdocs-material
- run: mkdocs gh-deploy --force
================================================
FILE: .github/workflows/make_test_ebook.yaml
================================================
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
BBM_CAIYUN_API_KEY: ${{ secrets.BBM_CAIYUN_API_KEY }}
jobs:
typos-check:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@v3
- name: Check spelling with custom config file
uses: crate-ci/typos@v1.16.6
with:
config: ./typos.toml
testing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: install python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: 'pip' # caching pip dependencies
- name: Check formatting (black)
run: |
pip install black
black . --check
- name: install python requirements
run: pip install -r requirements.txt
- name: Test install
run: |
pip install .
- name: make normal ebook test using google translate and cli
run: |
bbook_maker --book_name "test_books/Liber_Esther.epub" --test --test_num 10 --model google --translate-tags div,p
bbook_maker --book_name "test_books/Liber_Esther.epub" --test --test_num 20 --model google
- name: make txt book test using google translate
run: |
python3 make_book.py --book_name "test_books/the_little_prince.txt" --test --test_num 20 --model google
- name: make txt book test with batch_size
run: |
python3 make_book.py --book_name "test_books/the_little_prince.txt" --test --batch_size 30 --test_num 20 --model google
- name: make caiyun translator test
if: env.BBM_CAIYUN_API_KEY != null
run: |
python3 make_book.py --book_name "test_books/the_little_prince.txt" --test --batch_size 30 --test_num 100 --model caiyun
- name: make openai key ebook test
if: env.BBM_DEEPL_API_KEY != null
run: |
python3 make_book.py --book_name "test_books/lemo.epub" --test --test_num 5 --language zh-hans
python3 make_book.py --book_name "test_books/animal_farm.epub" --test --test_num 5 --language ja --model gpt3 --prompt prompt_template_sample.txt
python3 make_book.py --book_name "test_books/animal_farm.epub" --test --test_num 5 --language ja --prompt prompt_template_sample.json
python3 make_book.py --book_name test_books/Lex_Fridman_episode_322.srt --test --test_num 20
- name: Rename and Upload ePub
if: env.OPENAI_API_KEY != null
uses: actions/upload-artifact@v4
with:
name: epub_output
path: "test_books/lemo_bilingual.epub"
================================================
FILE: .github/workflows/release.yaml
================================================
name: Release and Build Docker Image
permissions:
contents: write
on:
push:
tags:
- "*"
jobs:
release-pypi:
name: Build and Release PyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-python@v4
with:
python-version: "3.10"
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Build artifacts
run: |
pip install build
python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.idea/
.DS_Store
test_books/
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
/test_books/*.epub
log/
.chatgpt_cache.json
# for user do not want to push
*.srt
*.txt
*.bin
*.epub
# For markdown files in user directories
.cursorrules
books/
prompts/
.pdm-python
================================================
FILE: Dockerfile
================================================
FROM python:3.10-slim
RUN apt-get update
WORKDIR /app
COPY requirements.txt .
RUN pip install -r /app/requirements.txt
COPY . .
ENTRYPOINT ["python3", "make_book.py"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 yihong
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.
================================================
FILE: Makefile
================================================
SHELL := /bin/bash
fmt:
@echo "Running formatter ..."
venv/bin/black .
.PHONY:tests
tests:
@echo "Running tests ..."
venv/bin/pytest tests/test_integration.py
serve-docs:
mkdocs serve
================================================
FILE: README-CN.md
================================================
# bilingual_book_maker
bilingual_book_maker 是一个 AI 翻译工具,使用 ChatGPT 帮助用户制作多语言版本的 epub/txt/srt 文件和图书。该工具仅适用于翻译进入公共版权领域的 epub/txt 图书,不适用于有版权的书籍。请在使用之前阅读项目的 **[免责声明](./disclaimer.md)**。

## 准备
1. ChatGPT or OpenAI token [^token]
2. epub/txt books
3. 能正常联网的环境或 proxy
4. python3.8+
## 快速开始
本地放了一个 `test_books/animal_farm.epub` 给大家测试
```shell
pip install -r requirements.txt
python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${openai_key} --test
或
pip install -U bbook_maker
bbook --book_name test_books/animal_farm.epub --openai_key ${openai_key} --test
```
## 翻译服务
- 使用 `--openai_key` 指定 OpenAI API key,如果有多个可以用英文逗号分隔(xxx,xxx,xxx),可以减少接口调用次数限制带来的错误。
或者,指定环境变量 `BBM_OPENAI_API_KEY` 来略过这个选项。
- 默认用了 [GPT-3.5-turbo](https://openai.com/blog/introducing-chatgpt-and-whisper-apis) 模型,也就是 ChatGPT 正在使用的模型。
* DeepL
使用 DeepL 封装的 api 进行翻译,需要付费。[DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator) 来获得 token
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key}
```
* DeepL free
使用 DeepL free
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model deeplfree
```
* Claude
使用 [Claude](https://console.anthropic.com/docs) 模型进行翻译
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model claude --claude_key ${claude_key}
```
* 谷歌翻译
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model google
```
* 彩云小译
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model caiyun --caiyun_key ${caiyun_key}
```
* Gemini
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model gemini --gemini_key ${gemini_key}
```
* Qwen
使用 [Qwen](https://www.aliyun.com/product/dashscope) 模型进行翻译,支持 qwen-mt-turbo 和 qwen-mt-plus 模型。
使用 `--source_lang` 指定源语言,留空为自动检测。
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --qwen_key ${qwen_key} --model qwen-mt-turbo --language "Simplified Chinese"
python3 make_book.py --book_name test_books/animal_farm.epub --qwen_key ${qwen_key} --model qwen-mt-plus --language "Japanese" --source_lang "English"
```
* 腾讯交互翻译
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model tencentransmart
```
* [xAI](https://x.ai)
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model xai --xai_key ${xai_key}
```
* [Ollama](https://github.com/ollama/ollama)
使用 [Ollama](https://github.com/ollama/ollama) 自托管模型进行翻译。
如果 ollama server 不运行在本地,使用 `--api_base http://x.x.x.x:port/v1` 指向 ollama server 地址
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --ollama_model ${ollama_model_name}
```
* [Groq](https://console.groq.com/keys)
GroqCloud 当前支持的模型可以查看[Supported Models](https://console.groq.com/docs/models)
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --groq_key [your_key] --model groq --model_list llama3-8b-8192
```
## 使用说明
- 翻译完会生成一本 `{book_name}_bilingual.epub` 的双语书
- 如果出现了错误或使用 `CTRL+C` 中断命令,不想接下来继续翻译了,会生成一本 `{book_name}_bilingual_temp.epub` 的书,直接改成你想要的名字就可以了
## 参数说明
- `--test`:
如果大家没付费可以加上这个先看看效果(有 limit 稍微有些慢)
- `--language`: 指定目标语言
- 例如: `--language "Simplified Chinese"`,预设值为 `"Simplified Chinese"`.
- 请阅读 helper message 来查找可用的目标语言: `python make_book.py --help`
- `--proxy`
方便中国大陆的用户在本地测试时使用代理,传入类似 `http://127.0.0.1:7890` 的字符串
- `--resume`
手动中断后,加入命令可以从之前中断的位置继续执行。
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model google --resume
```
- `--translate-tags`
指定需要翻译的标签,使用逗号分隔多个标签。epub 由 html 文件组成,默认情况下,只翻译 `
` 中的内容。例如: `--translate-tags h1,h2,h3,p,div`
- `--book_from`
选项指定电子阅读器类型(现在只有 kobo 可用),并使用 `--device_path` 指定挂载点。
- `--api_base ${url}`
如果你遇到了墙需要用 Cloudflare Workers 替换 api_base 请使用 `--api_base ${url}` 来替换。
**请注意,此处你输入的 api 应该是'`https://xxxx/v1`'的字样,域名需要用引号包裹**
- `--allow_navigable_strings`
如果你想要翻译电子书中的无标签字符串,可以使用 `--allow_navigable_strings` 参数,会将可遍历字符串加入翻译队列,**注意,在条件允许情况下,请寻找更规范的电子书**
- `--prompt`
如果你想调整 prompt,你可以使用 `--prompt` 参数。有效的占位符包括 `{text}` 和 `{language}`。你可以用以下方式配置 prompt:
- 如果您不需要设置 `system` 角色,可以这样:`--prompt "Translate {text} to {language}"` 或者 `--prompt prompt_template_sample.txt`(示例文本文件可以在 [./prompt_template_sample.txt](./prompt_template_sample.txt) 找到)。
- 如果您需要设置 `system` 角色,可以使用以下方式配置:`--prompt '{"user":"Translate {text} to {language}", "system": "You are a professional translator."}'`,或者 `--prompt prompt_template_sample.json`(示例 JSON 文件可以在 [./prompt_template_sample.json](./prompt_template_sample.json) 找到)。
- 你也可以用环境以下环境变量来配置 `system` 和 `user` 角色 prompt:`BBM_CHATGPTAPI_USER_MSG_TEMPLATE` 和 `BBM_CHATGPTAPI_SYS_MSG`。
该参数可以是提示模板字符串,也可以是模板 `.txt` 文件的路径。
- `--batch_size`
指定批量翻译的行数(默认行数为 10,目前只对 txt 生效)
- `--accumulated_num`:
达到累计token数开始进行翻译。gpt3.5将total_token限制为4090。
例如,如果您使用`--accumulation_num 1600`,则可能会输出2200个令牌,另外200个令牌用于系统指令(system_message)和用户指令(user_message),1600+2200+200 = 4000,所以token接近极限。你必须选择一个自己合适的值,我们无法在发送之前判断是否达到限制
- `--use_context`:
prompts the model to create a three-paragraph summary. If it's the beginning of the translation, it will summarize the entire passage sent (the size depending on `--accumulated_num`).
For subsequent passages, it will amend the summary to include details from the most recent passage, creating a running one-paragraph context payload of the important details of the entire translated work. This improves consistency of flow and tone throughout the translation. This option is available for all ChatGPT-compatible models and Gemini models.
模型提示词将创建三段摘要。如果是翻译的开始,它将总结发送的整个段落(大小取决于`--accumulated_num`)。
对于后续的段落,它将修改摘要,以包括最近段落的细节,创建一个完整的段落上下文负载,包含整个翻译作品的重要细节。 这提高了整个翻译过程中的流畅性和语气的一致性。 此选项适用于所有ChatGPT兼容型号和Gemini型号。
- `--context_paragraph_limit`:
使用`--use_context`选项时,使用`--context_paragraph_limit`设置上下文段落数限制。
- `--temperature`:
使用 `--temperature` 设置 `chatgptapi`/`gpt4`/`claude`模型的temperature值.
如 `--temperature 0.7`.
- `--block_size`:
使用`--block_size`将多个段落合并到一个块中。这可能会提高准确性并加快处理速度,但可能会干扰原始格式。必须与`--single_translate`一起使用。
例如:`--block_size 5 --single_translate`。
- `--single_translate`:
使用`--single_translate`只输出翻译后的图书,不创建双语版本。
- `--translation_style`:
如: `--translation_style "color: #808080; font-style: italic;"`
- `--retranslate "$translated_filepath" "file_name_in_epub" "start_str" "end_str"(optional)`:
- 重新翻译,从 start_str 到 end_str 的标记:
```shell
python3 "make_book.py" --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' 'index_split_002.html' 'in spite of the present book shortage which' 'This kind of thing is not a good symptom. Obviously'
```
- 重新翻译, 从start_str 的标记开始:
```shell
python3 "make_book.py" --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' 'index_split_002.html' 'in spite of the present book shortage which'
```
### 示范用例
**如果使用 `pip install bbook_maker` 以下命令都可以改成 `bbook args`**
```shell
# 如果你想快速测一下
python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${openai_key} --test
# 或翻译完整本书
python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${openai_key} --language zh-hans
# Or translate the whole book using Gemini
python3 make_book.py --book_name test_books/animal_farm.epub --gemini_key ${gemini_key} --model gemini
# 指定环境变量来略过 --openai_key
export OPENAI_API_KEY=${your_api_key}
# Use the DeepL model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key} --language ja
# Use the Claude model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model claude --claude_key ${claude_key} --language ja
# Use the CustomAPI model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model customapi --custom_api ${custom_api} --language ja
# Translate contents in
and
python3 make_book.py --book_name test_books/animal_farm.epub --translate-tags div,p
# 修改prompt
python3 make_book.py --book_name test_books/animal_farm.epub --prompt prompt_template_sample.txt
# 或者
python3 make_book.py --book_name test_books/animal_farm.epub --prompt "Please translate \`{text}\` to {language}"
# 翻译 kobo e-reader 中,來自 Rakuten Kobo 的书籍
python3 make_book.py --book_from kobo --device_path /tmp/kobo
# 翻译 txt 文件
python3 make_book.py --book_name test_books/the_little_prince.txt --test
# 聚合多行翻译 txt 文件
python3 make_book.py --book_name test_books/the_little_prince.txt --test --batch_size 20
# 使用彩云小译翻译(彩云api目前只支持: 简体中文 <-> 英文, 简体中文 <-> 日语)
# 彩云提供了测试token(3975l6lr5pcbvidl6jl2)
# 你可以参考这个教程申请自己的token (https://bobtranslate.com/service/translate/caiyun.html)
python3 make_book.py --model caiyun --caiyun_key 3975l6lr5pcbvidl6jl2 --book_name test_books/animal_farm.epub
# 可以在环境变量中设置BBM_CAIYUN_API_KEY,略过--openai_key
export BBM_CAIYUN_API_KEY=${your_api_key}
```
更加小白的示例
```shell
python3 make_book.py --book_name 'animal_farm.epub' --openai_key sk-XXXXX --api_base 'https://xxxxx/v1'
# 有可能你不需要 python3 而是python
python make_book.py --book_name 'animal_farm.epub' --openai_key sk-XXXXX --api_base 'https://xxxxx/v1'
```
[演示视频](https://www.bilibili.com/video/BV1XX4y1d75D/?t=0h07m08s)
[演示视频 2](https://www.bilibili.com/video/BV1T8411c7iU/)
使用 Azure OpenAI service
```shell
python3 make_book.py --book_name 'animal_farm.epub' --openai_key XXXXX --api_base 'https://example-endpoint.openai.azure.com' --deployment_id 'deployment-name'
# Or python3 is not in your PATH
python make_book.py --book_name 'animal_farm.epub' --openai_key XXXXX --api_base 'https://example-endpoint.openai.azure.com' --deployment_id 'deployment-name'
```
## 注意
1. Free trail 的 API token 有所限制,如果想要更快的速度,可以考虑付费方案
2. 欢迎提交 PR
# 感谢
- @[yetone](https://github.com/yetone)
# 贡献
- 任何 issue PR 都欢迎
- Issue 中有些 TODO 没做的都可以选
- 提交代码前请先执行 `black make_book.py` [^black]
# 其它推荐项目
- 书译 BookTranslator -> [Book Translator](https://www.booktranslator.app)
## 赞赏
谢谢就够了

[^token]: https://platform.openai.com/account/api-keys
[^black]: https://github.com/psf/black
================================================
FILE: README.md
================================================
**[中文](./README-CN.md) | English**
[](https://github.com/BerriAI/litellm)
# bilingual_book_maker
The bilingual_book_maker is an AI translation tool that uses ChatGPT to assist users in creating multi-language versions of epub/txt/srt/pdf files and books. This tool is exclusively designed for translating epub and other public domain works and is not intended for copyrighted works. Before using this tool, please review the project's **[disclaimer](./disclaimer.md)**.

## Supported Models
gpt-5-mini, gpt-4, gpt-3.5-turbo, claude-2, palm, llama-2, azure-openai, command-nightly, gemini, qwen-mt-turbo, qwen-mt-plus
For using Non-OpenAI models, use class `liteLLM()` - liteLLM supports all models above.
Find more info here for using liteLLM: https://github.com/BerriAI/litellm/blob/main/setup.py
## Preparation
1. ChatGPT or OpenAI token [^token]
2. epub/txt/pdf books
3. Environment with internet access or proxy
4. Python 3.8+
## Quick Start
A sample book, `test_books/animal_farm.epub`, is provided for testing purposes.
```shell
pip install -r requirements.txt
python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${openai_key} --test
OR
pip install -U bbook_maker
bbook --book_name test_books/animal_farm.epub --openai_key ${openai_key} --test
```
## Translate Service
- Use `--openai_key` option to specify OpenAI API key. If you have multiple keys, separate them by commas (xxx,xxx,xxx) to reduce errors caused by API call limits.
Or, just set environment variable `BBM_OPENAI_API_KEY` instead.
- A sample book, `test_books/animal_farm.epub`, is provided for testing purposes.
- The default underlying model is [GPT-3.5-turbo](https://openai.com/blog/introducing-chatgpt-and-whisper-apis), which is used by ChatGPT currently. Use `--model gpt4` to change the underlying model to `GPT4`. You can also use `GPT4omini`.
- Important to note that `gpt-4` is significantly more expensive than `gpt-4-turbo`, but to avoid bumping into rate limits, we automatically balance queries across `gpt-4-1106-preview`, `gpt-4`, `gpt-4-32k`, `gpt-4-0613`,`gpt-4-32k-0613`.
- If you want to use a specific model alias with OpenAI (eg `gpt-4-1106-preview` or `gpt-3.5-turbo-0125`), you can use `--model openai --model_list gpt-4-1106-preview,gpt-3.5-turbo-0125`. `--model_list` takes a comma-separated list of model aliases.
- If using chatgptapi, you can add `--use_context` to add a context paragraph to each passage sent to the model for translation (see below).
* DeepL
Support DeepL model [DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator) need pay to get the token
```
python3 make_book.py --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key}
```
* DeepL free
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model deeplfree
```
* [Claude](https://console.anthropic.com/docs)
Use [Claude](https://console.anthropic.com/docs) model to translate
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model claude --claude_key ${claude_key}
```
* Google Translate
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model google
```
* Caiyun Translate
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model caiyun --caiyun_key ${caiyun_key}
```
* Gemini
Support Google [Gemini](https://aistudio.google.com/app/apikey) model, use `--model gemini` for Gemini Flash or `--model geminipro` for Gemini Pro.
If you want to use a specific model alias with Gemini (eg `gemini-1.5-flash-002` or `gemini-1.5-flash-8b-exp-0924`), you can use `--model gemini --model_list gemini-1.5-flash-002,gemini-1.5-flash-8b-exp-0924`. `--model_list` takes a comma-separated list of model aliases.
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model gemini --gemini_key ${gemini_key}
```
* Qwen
Support Alibaba Cloud [Qwen-MT](https://bailian.console.aliyun.com/) specialized translation model. Supports 92 languages with features like terminology intervention and translation memory.
Use `--model qwen-mt-turbo` for faster/cheaper translation, or `--model qwen-mt-plus` for higher quality.
Use `source_lang` to specify the source language explicitly, or leave it empty for auto-detection.
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --qwen_key ${qwen_key} --model qwen-mt-turbo --language "Simplified Chinese"
python3 make_book.py --book_name test_books/animal_farm.epub --qwen_key ${qwen_key} --model qwen-mt-plus --language "Japanese" --source_lang "English"
```
* [Tencent TranSmart](https://transmart.qq.com)
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model tencentransmart
```
* [xAI](https://x.ai)
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model xai --xai_key ${xai_key}
```
* [Ollama](https://github.com/ollama/ollama)
Support [Ollama](https://github.com/ollama/ollama) self-host models,
If ollama server is not running on localhost, use `--api_base http://x.x.x.x:port/v1` to point to the ollama server address
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --ollama_model ${ollama_model_name}
```
* [groq](https://console.groq.com/keys)
GroqCloud currently supports models: you can find from [Supported Models](https://console.groq.com/docs/models)
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --groq_key [your_key] --model groq --model_list llama3-8b-8192
```
## Use
- Once the translation is complete, a bilingual book named `${book_name}_bilingual.epub` would be generated for EPUB inputs; for TXT/MD/SRT inputs a bilingual text (or subtitle) file named `${book_name}_bilingual.txt` (or `_bilingual.srt`) will be generated. For **PDF inputs** the tool will produce a bilingual `.txt` fallback and will also attempt to create `${book_name}_bilingual.epub` — if EPUB creation fails, the TXT fallback remains so you do not need to retranslate.
- If there are any errors or you wish to interrupt the translation by pressing `CTRL+C`, a temporary bilingual file (for example `{book_name}_bilingual_temp.epub` or `{book_name}_bilingual_temp.txt`) would be generated. You can simply rename it to any desired name.
## Params
- `--test`:
Use `--test` option to preview the result if you haven't paid for the service. Note that there is a limit and it may take some time.
- `--language`:
Set the target language like `--language "Simplified Chinese"`. Default target language is `"Simplified Chinese"`.
Read available languages by helper message: `python make_book.py --help`
- `--proxy`:
Use `--proxy` option to specify proxy server for internet access. Enter a string such as `http://127.0.0.1:7890`.
- `--resume`:
Use `--resume` option to manually resume the process after an interruption.
```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model google --resume
```
- `--translate-tags`:
epub is made of html files. By default, we only translate contents in `
`.
Use `--translate-tags` to specify tags need for translation. Use comma to separate multiple tags.
For example: `--translate-tags h1,h2,h3,p,div`
- `--book_from`:
Use `--book_from` option to specify e-reader type (Now only `kobo` is available), and use `--device_path` to specify the mounting point.
- `--api_base`:
If you want to change api_base like using Cloudflare Workers, use `--api_base ` to support it.
**Note: the api url should be '`https://xxxx/v1`'. Quotation marks are required.**
- `--allow_navigable_strings`:
If you want to translate strings in an e-book that aren't labeled with any tags, you can use the `--allow_navigable_strings` parameter. This will add the strings to the translation queue. **Note that it's best to look for e-books that are more standardized if possible.**
- `--prompt`:
To tweak the prompt, use the `--prompt` parameter. Valid placeholders for the `user` role template include `{text}` and `{language}`. It supports a few ways to configure the prompt:
- If you don't need to set the `system` role content, you can simply set it up like this: `--prompt "Translate {text} to {language}."` or `--prompt prompt_template_sample.txt` (example of a text file can be found at [./prompt_template_sample.txt](./prompt_template_sample.txt)).
- If you need to set the `system` role content, you can use the following format: `--prompt '{"user":"Translate {text} to {language}", "system": "You are a professional translator."}'` or `--prompt prompt_template_sample.json` (example of a JSON file can be found at [./prompt_template_sample.json](./prompt_template_sample.json)).
- You can now use [PromptDown](https://github.com/btfranklin/promptdown) format (`.md` files) for more structured prompts: `--prompt prompt_md.prompt.md`. PromptDown supports both traditional system messages and developer messages (used by newer AI models). Example:
```markdown
# Translation Prompt
## Developer Message
You are a professional translator who specializes in accurate translations.
## Conversation
| Role | Content |
| ---- | -------------------------------------------------------------- |
| User | Please translate the following text into {language}:\n\n{text} |
```
- You can also set the `user` and `system` role prompt by setting environment variables: `BBM_CHATGPTAPI_USER_MSG_TEMPLATE` and `BBM_CHATGPTAPI_SYS_MSG`.
- `--batch_size`:
Use the `--batch_size` parameter to specify the number of lines for batch translation (default is 10, currently only effective for txt files).
- `--accumulated_num`:
Wait for how many tokens have been accumulated before starting the translation. gpt3.5 limits the total_token to 4090. For example, if you use `--accumulated_num 1600`, maybe openai will output 2200 tokens and maybe 200 tokens for other messages in the system messages user messages, 1600+2200+200=4000, So you are close to reaching the limit. You have to choose your own
value, there is no way to know if the limit is reached before sending
- `--use_context`:
prompts the model to create a three-paragraph summary. If it's the beginning of the translation, it will summarize the entire passage sent (the size depending on `--accumulated_num`).
For subsequent passages, it will amend the summary to include details from the most recent passage, creating a running one-paragraph context payload of the important details of the entire translated work. This improves consistency of flow and tone throughout the translation. This option is available for all ChatGPT-compatible models and Gemini models.
- `--context_paragraph_limit`:
Use `--context_paragraph_limit` to set a limit on the number of context paragraphs when using the `--use_context` option.
- `--parallel-workers`:
Use `--parallel-workers` to enable parallel EPUB chapter processing. Values greater than `1` spin up multiple workers (recommended: `2-4`) and automatically fall back to sequential mode for single-chapter books.
- `--temperature`:
Use `--temperature` to set the temperature parameter for `chatgptapi`/`gpt4`/`claude` models.
For example: `--temperature 0.7`.
- `--block_size`:
Use `--block_size` to merge multiple paragraphs into one block. This may increase accuracy and speed up the process but can disturb the original format. Must be used with `--single_translate`.
For example: `--block_size 5 --single_translate`.
- `--single_translate`:
Use `--single_translate` to output only the translated book without creating a bilingual version.
- `--translation_style`:
example: `--translation_style "color: #808080; font-style: italic;"`
- `--retranslate "$translated_filepath" "file_name_in_epub" "start_str" "end_str"(optional)`:
Retranslate from start_str to end_str's tag:
```shell
python3 "make_book.py" --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' 'index_split_002.html' 'in spite of the present book shortage which' 'This kind of thing is not a good symptom. Obviously'
```
Retranslate start_str's tag:
```shell
python3 "make_book.py" --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' 'index_split_002.html' 'in spite of the present book shortage which'
```
### Examples
**Note if use `pip install bbook_maker` all commands can change to `bbook_maker args`**
```shell
# Test quickly
python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${openai_key} --test --language zh-hans
# Test quickly for src
python3 make_book.py --book_name test_books/Lex_Fridman_episode_322.srt --openai_key ${openai_key} --test
# Or translate the whole book
python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${openai_key} --language zh-hans
# Or translate the whole book using Gemini flash
python3 make_book.py --book_name test_books/animal_farm.epub --gemini_key ${gemini_key} --model gemini
# Translate an EPUB with parallel chapter processing
python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${openai_key} --parallel-workers 4
# Use a specific list of Gemini model aliases
python3 make_book.py --book_name test_books/animal_farm.epub --gemini_key ${gemini_key} --model gemini --model_list gemini-1.5-flash-002,gemini-1.5-flash-8b-exp-0924
# Set env OPENAI_API_KEY to ignore option --openai_key
export OPENAI_API_KEY=${your_api_key}
# Use the GPT-4 model with context to Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model gpt4 --use_context --language ja
# Use a specific OpenAI model alias
python3 make_book.py --book_name test_books/animal_farm.epub --model openai --model_list gpt-4-1106-preview --openai_key ${openai_key}
**Note** you can use other `openai like` model in this way
python3 make_book.py --book_name test_books/animal_farm.epub --model openai --model_list yi-34b-chat-0205 --openai_key ${openai_key} --api_base "https://api.lingyiwanwu.com/v1"
# Use a specific list of OpenAI model aliases
python3 make_book.py --book_name test_books/animal_farm.epub --model openai --model_list gpt-4-1106-preview,gpt-4-0125-preview,gpt-3.5-turbo-0125 --openai_key ${openai_key}
# Use the DeepL model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key} --language ja
# Use the Claude model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model claude --claude_key ${claude_key} --language ja
# Use the CustomAPI model with Japanese
python3 make_book.py --book_name test_books/animal_farm.epub --model customapi --custom_api ${custom_api} --language ja
# Translate contents in
and
python3 make_book.py --book_name test_books/animal_farm.epub --translate-tags div,p
# Tweaking the prompt
python3 make_book.py --book_name test_books/animal_farm.epub --prompt prompt_template_sample.txt
# or
python3 make_book.py --book_name test_books/animal_farm.epub --prompt prompt_template_sample.json
# or
python3 make_book.py --book_name test_books/animal_farm.epub --prompt "Please translate \`{text}\` to {language}"
# Translate books download from Rakuten Kobo on kobo e-reader
python3 make_book.py --book_from kobo --device_path /tmp/kobo
# translate txt file
python3 make_book.py --book_name test_books/the_little_prince.txt --test --language zh-hans
# aggregated translation txt file
python3 make_book.py --book_name test_books/the_little_prince.txt --test --batch_size 20
# Using Caiyun model to translate
# (the api currently only support: simplified chinese <-> english, simplified chinese <-> japanese)
# the official Caiyun has provided a test token (3975l6lr5pcbvidl6jl2)
# you can apply your own token by following this tutorial(https://bobtranslate.com/service/translate/caiyun.html)
python3 make_book.py --model caiyun --caiyun_key 3975l6lr5pcbvidl6jl2 --book_name test_books/animal_farm.epub
# Set env BBM_CAIYUN_API_KEY to ignore option --openai_key
export BBM_CAIYUN_API_KEY=${your_api_key}
```
More understandable example
```shell
python3 make_book.py --book_name 'animal_farm.epub' --openai_key sk-XXXXX --api_base 'https://xxxxx/v1'
# Or python3 is not in your PATH
python make_book.py --book_name 'animal_farm.epub' --openai_key sk-XXXXX --api_base 'https://xxxxx/v1'
```
Microsoft Azure Endpoints
```shell
python3 make_book.py --book_name 'animal_farm.epub' --openai_key XXXXX --api_base 'https://example-endpoint.openai.azure.com' --deployment_id 'deployment-name'
# Or python3 is not in your PATH
python make_book.py --book_name 'animal_farm.epub' --openai_key XXXXX --api_base 'https://example-endpoint.openai.azure.com' --deployment_id 'deployment-name'
```
## Docker
You can use [Docker](https://www.docker.com/) if you don't want to deal with setting up the environment.
```shell
# Build image
docker build --tag bilingual_book_maker .
# Run container
# "$folder_path" represents the folder where your book file locates. Also, it is where the processed file will be stored.
# Windows PowerShell
$folder_path=your_folder_path # $folder_path="C:\Users\user\mybook\"
$book_name=your_book_name # $book_name="animal_farm.epub"
$openai_key=your_api_key # $openai_key="sk-xxx"
$language=your_language # see utils.py
docker run --rm --name bilingual_book_maker --mount type=bind,source=$folder_path,target='/app/test_books' bilingual_book_maker --book_name "/app/test_books/$book_name" --openai_key $openai_key --language $language
# Linux
export folder_path=${your_folder_path}
export book_name=${your_book_name}
export openai_key=${your_api_key}
export language=${your_language}
docker run --rm --name bilingual_book_maker --mount type=bind,source=${folder_path},target='/app/test_books' bilingual_book_maker --book_name "/app/test_books/${book_name}" --openai_key ${openai_key} --language "${language}"
```
For example:
```shell
# Linux
docker run --rm --name bilingual_book_maker --mount type=bind,source=/home/user/my_books,target='/app/test_books' bilingual_book_maker --book_name /app/test_books/animal_farm.epub --openai_key sk-XXX --test --test_num 1 --language zh-hant
```
## Notes
1. API token from free trial has limit. If you want to speed up the process, consider paying for the service or use multiple OpenAI tokens
2. PR is welcome
# Thanks
- @[yetone](https://github.com/yetone)
# Contribution
- Any issues or PRs are welcome.
- TODOs in the issue can also be selected.
- Please run `black make_book.py`[^black] before submitting the code.
# Others better
- 书译 BookTranslator -> [Book Translator](https://www.booktranslator.app)
## Appreciation
Thank you, that's enough.

[^token]: https://platform.openai.com/account/api-keys
[^black]: https://github.com/psf/black
================================================
FILE: book_maker/__init__.py
================================================
================================================
FILE: book_maker/__main__.py
================================================
from cli import main
if __name__ == "__main__":
main()
================================================
FILE: book_maker/cli.py
================================================
import argparse
import json
import os
from os import environ as env
from book_maker.loader import BOOK_LOADER_DICT
from book_maker.translator import MODEL_DICT
from book_maker.utils import LANGUAGES, TO_LANGUAGE_CODE
def parse_prompt_arg(prompt_arg):
prompt = None
if prompt_arg is None:
return prompt
# Check if it's a path to a markdown file (PromptDown format)
if prompt_arg.endswith(".md") and os.path.exists(prompt_arg):
try:
from promptdown import StructuredPrompt
structured_prompt = StructuredPrompt.from_promptdown_file(prompt_arg)
# Initialize our prompt structure
prompt = {}
# Handle developer_message or system_message
# Developer message takes precedence if both are present
if (
hasattr(structured_prompt, "developer_message")
and structured_prompt.developer_message
):
prompt["system"] = structured_prompt.developer_message
elif (
hasattr(structured_prompt, "system_message")
and structured_prompt.system_message
):
prompt["system"] = structured_prompt.system_message
# Extract user message from conversation
if (
hasattr(structured_prompt, "conversation")
and structured_prompt.conversation
):
for message in structured_prompt.conversation:
if message.role.lower() == "user":
prompt["user"] = message.content
break
# Ensure we found a user message
if "user" not in prompt or not prompt["user"]:
raise ValueError(
"PromptDown file must contain at least one user message"
)
print(f"Successfully loaded PromptDown file: {prompt_arg}")
# Validate required placeholders
if any(c not in prompt["user"] for c in ["{text}"]):
raise ValueError(
"User message in PromptDown must contain `{text}` placeholder"
)
return prompt
except Exception as e:
print(f"Error parsing PromptDown file: {e}")
# Fall through to other parsing methods
# Existing parsing logic for JSON strings and other formats
if not any(prompt_arg.endswith(ext) for ext in [".json", ".txt", ".md"]):
try:
# user can define prompt by passing a json string
# eg: --prompt '{"system": "You are a professional translator who translates computer technology books", "user": "Translate \`{text}\` to {language}"}'
prompt = json.loads(prompt_arg)
except json.JSONDecodeError:
# if not a json string, treat it as a template string
prompt = {"user": prompt_arg}
elif os.path.exists(prompt_arg):
if prompt_arg.endswith(".txt"):
# if it's a txt file, treat it as a template string
with open(prompt_arg, encoding="utf-8") as f:
prompt = {"user": f.read()}
elif prompt_arg.endswith(".json"):
# if it's a json file, treat it as a json object
# eg: --prompt prompt_template_sample.json
with open(prompt_arg, encoding="utf-8") as f:
prompt = json.load(f)
else:
raise FileNotFoundError(f"{prompt_arg} not found")
# if prompt is None or any(c not in prompt["user"] for c in ["{text}", "{language}"]):
if prompt is None or any(c not in prompt["user"] for c in ["{text}"]):
raise ValueError("prompt must contain `{text}`")
if "user" not in prompt:
raise ValueError("prompt must contain the key of `user`")
if (prompt.keys() - {"user", "system"}) != set():
raise ValueError("prompt can only contain the keys of `user` and `system`")
print("prompt config:", prompt)
return prompt
def main():
translate_model_list = list(MODEL_DICT.keys())
parser = argparse.ArgumentParser()
parser.add_argument(
"--book_name",
dest="book_name",
type=str,
help="path of the epub file to be translated",
)
parser.add_argument(
"--book_from",
dest="book_from",
type=str,
choices=["kobo"], # support kindle later
metavar="E-READER",
help="e-reader type, available: {%(choices)s}",
)
parser.add_argument(
"--device_path",
dest="device_path",
type=str,
help="Path of e-reader device",
)
########## KEYS ##########
parser.add_argument(
"--openai_key",
dest="openai_key",
type=str,
default="",
help="OpenAI api key,if you have more than one key, please use comma"
" to split them to go beyond the rate limits",
)
parser.add_argument(
"--caiyun_key",
dest="caiyun_key",
type=str,
help="you can apply caiyun key from here (https://dashboard.caiyunapp.com/user/sign_in/)",
)
parser.add_argument(
"--deepl_key",
dest="deepl_key",
type=str,
help="you can apply deepl key from here (https://rapidapi.com/splintPRO/api/dpl-translator",
)
parser.add_argument(
"--claude_key",
dest="claude_key",
type=str,
help="you can find claude key from here (https://console.anthropic.com/account/keys)",
)
parser.add_argument(
"--custom_api",
dest="custom_api",
type=str,
help="you should build your own translation api",
)
# for Google Gemini
parser.add_argument(
"--gemini_key",
dest="gemini_key",
type=str,
help="You can get Gemini Key from https://makersuite.google.com/app/apikey",
)
# for Groq
parser.add_argument(
"--groq_key",
dest="groq_key",
type=str,
help="You can get Groq Key from https://console.groq.com/keys",
)
# for xAI
parser.add_argument(
"--xai_key",
dest="xai_key",
type=str,
help="You can get xAI Key from https://console.x.ai/",
)
# for Qwen
parser.add_argument(
"--qwen_key",
dest="qwen_key",
type=str,
help="You can get Qwen Key from https://bailian.console.aliyun.com/?tab=model#/api-key",
)
parser.add_argument(
"--test",
dest="test",
action="store_true",
help="only the first 10 paragraphs will be translated, for testing",
)
parser.add_argument(
"--test_num",
dest="test_num",
type=int,
default=10,
help="how many paragraphs will be translated for testing",
)
parser.add_argument(
"-m",
"--model",
dest="model",
type=str,
default="chatgptapi",
choices=translate_model_list, # support DeepL later
metavar="MODEL",
help="model to use, available: {%(choices)s}",
)
parser.add_argument(
"--ollama_model",
dest="ollama_model",
type=str,
default="",
metavar="MODEL",
help="use ollama",
)
parser.add_argument(
"--language",
type=str,
choices=sorted(LANGUAGES.keys())
+ sorted([k.title() for k in TO_LANGUAGE_CODE]),
default="zh-hans",
metavar="LANGUAGE",
help="language to translate to, available: {%(choices)s}",
)
parser.add_argument(
"--resume",
dest="resume",
action="store_true",
help="if program stop unexpected you can use this to resume",
)
parser.add_argument(
"-p",
"--proxy",
dest="proxy",
type=str,
default="",
help="use proxy like http://127.0.0.1:7890",
)
parser.add_argument(
"--deployment_id",
dest="deployment_id",
type=str,
help="the deployment name you chose when you deployed the model",
)
# args to change api_base
parser.add_argument(
"--api_base",
metavar="API_BASE_URL",
dest="api_base",
type=str,
help="specify base url other than the OpenAI's official API address",
)
parser.add_argument(
"--exclude_filelist",
dest="exclude_filelist",
type=str,
default="",
help="if you have more than one file to exclude, please use comma to split them, example: --exclude_filelist 'nav.xhtml,cover.xhtml'",
)
parser.add_argument(
"--only_filelist",
dest="only_filelist",
type=str,
default="",
help="if you only have a few files with translations, please use comma to split them, example: --only_filelist 'nav.xhtml,cover.xhtml'",
)
parser.add_argument(
"--translate-tags",
dest="translate_tags",
type=str,
default="p",
help="example --translate-tags p,blockquote",
)
parser.add_argument(
"--exclude_translate-tags",
dest="exclude_translate_tags",
type=str,
default="sup",
help="example --exclude_translate-tags table,sup",
)
parser.add_argument(
"--allow_navigable_strings",
dest="allow_navigable_strings",
action="store_true",
default=False,
help="allow NavigableStrings to be translated",
)
parser.add_argument(
"--prompt",
dest="prompt_arg",
type=str,
metavar="PROMPT_ARG",
help="used for customizing the prompt. It can be the prompt template string, or a path to the template file. The valid placeholders are `{text}` and `{language}`.",
)
parser.add_argument(
"--accumulated_num",
dest="accumulated_num",
type=int,
default=1,
help="""Wait for how many tokens have been accumulated before starting the translation.
gpt3.5 limits the total_token to 4090.
For example, if you use --accumulated_num 1600, maybe openai will output 2200 tokens
and maybe 200 tokens for other messages in the system messages user messages, 1600+2200+200=4000,
So you are close to reaching the limit. You have to choose your own value, there is no way to know if the limit is reached before sending
""",
)
parser.add_argument(
"--translation_style",
dest="translation_style",
type=str,
help="""ex: --translation_style "color: #808080; font-style: italic;" """,
)
parser.add_argument(
"--batch_size",
dest="batch_size",
type=int,
help="how many lines will be translated by aggregated translation(This options currently only applies to txt files)",
)
parser.add_argument(
"--retranslate",
dest="retranslate",
nargs=4,
type=str,
help="""--retranslate "$translated_filepath" "file_name_in_epub" "start_str" "end_str"(optional)
Retranslate from start_str to end_str's tag:
python3 "make_book.py" --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' 'index_split_002.html' 'in spite of the present book shortage which' 'This kind of thing is not a good symptom. Obviously'
Retranslate start_str's tag:
python3 "make_book.py" --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' 'index_split_002.html' 'in spite of the present book shortage which'
""",
)
parser.add_argument(
"--single_translate",
action="store_true",
help="output translated book, no bilingual",
)
parser.add_argument(
"--use_context",
dest="context_flag",
action="store_true",
help="adds an additional paragraph for global, updating historical context of the story to the model's input, improving the narrative consistency for the AI model (this uses ~200 more tokens each time)",
)
parser.add_argument(
"--context_paragraph_limit",
dest="context_paragraph_limit",
type=int,
default=0,
help="if use --use_context, set context paragraph limit",
)
parser.add_argument(
"--temperature",
type=float,
default=1.0,
help="temperature parameter for `chatgptapi`/`gpt4`/`gpt4omini`/`gpt4o`/`gpt5mini`/`claude`/`gemini`",
)
parser.add_argument(
"--source_lang",
type=str,
default="auto",
help="source language for translation models like `qwen` (default: auto-detect)",
)
parser.add_argument(
"--block_size",
type=int,
default=-1,
help="merge multiple paragraphs into one block, may increase accuracy and speed up the process, but disturb the original format, must be used with `--single_translate`",
)
parser.add_argument(
"--model_list",
type=str,
dest="model_list",
help="Rather than using our preset lists of models, specify exactly the models you want as a comma separated list `gpt-4-32k,gpt-3.5-turbo-0125` (Currently only supports: `openai`)",
)
parser.add_argument(
"--batch",
dest="batch_flag",
action="store_true",
help="Enable batch translation using ChatGPT's batch API for improved efficiency",
)
parser.add_argument(
"--batch-use",
dest="batch_use_flag",
action="store_true",
help="Use pre-generated batch translations to create files. Run with --batch first before using this option",
)
parser.add_argument(
"--interval",
type=float,
default=0.01,
help="Request interval in seconds (e.g., 0.1 for 100ms). Currently only supported for Gemini models. Default: 0.01",
)
parser.add_argument(
"--parallel-workers",
dest="parallel_workers",
type=int,
default=1,
help="Number of parallel workers for EPUB chapter processing. Use 2-4 for better performance. Default: 1",
)
options = parser.parse_args()
if not options.book_name:
print("Error: please provide the path of your book using --book_name ")
exit(1)
if not os.path.isfile(options.book_name):
print(f"Error: the book {options.book_name!r} does not exist.")
exit(1)
PROXY = options.proxy
if PROXY != "":
os.environ["http_proxy"] = PROXY
os.environ["https_proxy"] = PROXY
translate_model = MODEL_DICT.get(options.model)
assert translate_model is not None, "unsupported model"
API_KEY = ""
if options.model in [
"openai",
"chatgptapi",
"gpt4",
"gpt4omini",
"gpt4o",
"gpt5mini",
"o1preview",
"o1",
"o1mini",
"o3mini",
]:
if OPENAI_API_KEY := (
options.openai_key
or env.get(
"OPENAI_API_KEY",
) # XXX: for backward compatibility, deprecate soon
or env.get(
"BBM_OPENAI_API_KEY",
) # suggest adding `BBM_` prefix for all the bilingual_book_maker ENVs.
):
API_KEY = OPENAI_API_KEY
# patch
elif options.ollama_model:
# any string is ok, can't be empty
API_KEY = "ollama"
else:
raise Exception(
"OpenAI API key not provided, please google how to obtain it",
)
elif options.model == "caiyun":
API_KEY = options.caiyun_key or env.get("BBM_CAIYUN_API_KEY")
if not API_KEY:
raise Exception("Please provide caiyun key")
elif options.model == "deepl":
API_KEY = options.deepl_key or env.get("BBM_DEEPL_API_KEY")
if not API_KEY:
raise Exception("Please provide deepl key")
elif options.model.startswith("claude"):
API_KEY = options.claude_key or env.get("BBM_CLAUDE_API_KEY")
if not API_KEY:
raise Exception("Please provide claude key")
elif options.model == "customapi":
API_KEY = options.custom_api or env.get("BBM_CUSTOM_API")
if not API_KEY:
raise Exception("Please provide custom translate api")
elif options.model in ["gemini", "geminipro"]:
API_KEY = options.gemini_key or env.get("BBM_GOOGLE_GEMINI_KEY")
elif options.model == "groq":
API_KEY = options.groq_key or env.get("BBM_GROQ_API_KEY")
elif options.model == "xai":
API_KEY = options.xai_key or env.get("BBM_XAI_API_KEY")
elif options.model.startswith("qwen-"):
API_KEY = options.qwen_key or env.get("BBM_QWEN_API_KEY")
else:
API_KEY = ""
if options.book_from == "kobo":
from book_maker import obok
device_path = options.device_path
if device_path is None:
raise Exception(
"Device path is not given, please specify the path by --device_path ",
)
options.book_name = obok.cli_main(device_path)
book_type = options.book_name.split(".")[-1]
support_type_list = list(BOOK_LOADER_DICT.keys())
if book_type not in support_type_list:
raise Exception(
f"now only support files of these formats: {','.join(support_type_list)}",
)
if options.block_size > 0 and not options.single_translate:
raise Exception(
"block_size must be used with `--single_translate` because it disturbs the original format",
)
book_loader = BOOK_LOADER_DICT.get(book_type)
assert book_loader is not None, "unsupported loader"
language = options.language
if options.language in LANGUAGES:
# use the value for prompt
language = LANGUAGES.get(language, language)
# change api_base for issue #42
model_api_base = options.api_base
if options.ollama_model and not model_api_base:
# ollama default api_base
model_api_base = "http://localhost:11434/v1"
e = book_loader(
options.book_name,
translate_model,
API_KEY,
options.resume,
language=language,
model_api_base=model_api_base,
is_test=options.test,
test_num=options.test_num,
prompt_config=parse_prompt_arg(options.prompt_arg),
single_translate=options.single_translate,
context_flag=options.context_flag,
context_paragraph_limit=options.context_paragraph_limit,
temperature=options.temperature,
source_lang=options.source_lang,
parallel_workers=options.parallel_workers,
)
# other options
if options.allow_navigable_strings:
e.allow_navigable_strings = True
if options.translate_tags:
e.translate_tags = options.translate_tags
if options.exclude_translate_tags:
e.exclude_translate_tags = options.exclude_translate_tags
if options.exclude_filelist:
e.exclude_filelist = options.exclude_filelist
if options.only_filelist:
e.only_filelist = options.only_filelist
if options.accumulated_num > 1:
e.accumulated_num = options.accumulated_num
if options.translation_style:
e.translation_style = options.translation_style
if options.batch_size:
e.batch_size = options.batch_size
if options.retranslate:
e.retranslate = options.retranslate
if options.deployment_id:
# only work for ChatGPT api for now
# later maybe support others
assert options.model in [
"chatgptapi",
"gpt4",
"gpt4omini",
"gpt4o",
"gpt5mini",
"o1",
"o1preview",
"o1mini",
"o3mini",
], "only support chatgptapi for deployment_id"
if not options.api_base:
raise ValueError("`api_base` must be provided when using `deployment_id`")
e.translate_model.set_deployment_id(options.deployment_id)
if options.model in ("openai", "groq"):
# Currently only supports `openai` when you also have --model_list set
if options.model_list:
e.translate_model.set_model_list(options.model_list.split(","))
else:
raise ValueError(
"When using `openai` model, you must also provide `--model_list`. For default model sets use `--model chatgptapi` or `--model gpt4` or `--model gpt4omini` or `--model gpt5mini`",
)
# TODO refactor, quick fix for gpt4 model
if options.model == "chatgptapi":
if options.ollama_model:
e.translate_model.set_gpt35_models(ollama_model=options.ollama_model)
else:
e.translate_model.set_gpt35_models()
if options.model == "gpt4":
e.translate_model.set_gpt4_models()
if options.model == "gpt4omini":
e.translate_model.set_gpt4omini_models()
if options.model == "gpt4o":
e.translate_model.set_gpt4o_models()
if options.model == "gpt5mini":
e.translate_model.set_gpt5mini_models()
if options.model == "o1preview":
e.translate_model.set_o1preview_models()
if options.model == "o1":
e.translate_model.set_o1_models()
if options.model == "o1mini":
e.translate_model.set_o1mini_models()
if options.model == "o3mini":
e.translate_model.set_o3mini_models()
if options.model.startswith("claude-"):
e.translate_model.set_claude_model(options.model)
if options.model.startswith("qwen-"):
e.translate_model.set_qwen_model(options.model)
if options.block_size > 0:
e.block_size = options.block_size
if options.batch_flag:
e.batch_flag = options.batch_flag
if options.batch_use_flag:
e.batch_use_flag = options.batch_use_flag
if options.model in ("gemini", "geminipro"):
e.translate_model.set_interval(options.interval)
if options.model == "gemini":
if options.model_list:
e.translate_model.set_model_list(options.model_list.split(","))
else:
e.translate_model.set_geminiflash_models()
if options.model == "geminipro":
e.translate_model.set_geminipro_models()
e.make_bilingual_book()
if __name__ == "__main__":
main()
================================================
FILE: book_maker/config.py
================================================
config = {
"translator": {
"chatgptapi": {
"context_paragraph_limit": 3,
"batch_context_update_interval": 50,
}
},
}
================================================
FILE: book_maker/loader/__init__.py
================================================
from book_maker.loader.epub_loader import EPUBBookLoader
from book_maker.loader.txt_loader import TXTBookLoader
from book_maker.loader.srt_loader import SRTBookLoader
from book_maker.loader.md_loader import MarkdownBookLoader
from book_maker.loader.pdf_loader import PDFBookLoader
BOOK_LOADER_DICT = {
"epub": EPUBBookLoader,
"txt": TXTBookLoader,
"srt": SRTBookLoader,
"md": MarkdownBookLoader,
"pdf": PDFBookLoader,
# TODO add more here
}
================================================
FILE: book_maker/loader/base_loader.py
================================================
from abc import ABC, abstractmethod
class BaseBookLoader(ABC):
@staticmethod
def _is_special_text(text):
return text.isdigit() or text.isspace()
@abstractmethod
def _make_new_book(self, book):
pass
@abstractmethod
def make_bilingual_book(self):
pass
@abstractmethod
def load_state(self):
pass
@abstractmethod
def _save_temp_book(self):
pass
@abstractmethod
def _save_progress(self):
pass
================================================
FILE: book_maker/loader/epub_loader.py
================================================
import os
import pickle
import string
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from copy import copy
from pathlib import Path
import traceback
from threading import Lock
from bs4 import BeautifulSoup as bs
from bs4 import Tag
from bs4.element import NavigableString
from ebooklib import ITEM_DOCUMENT, epub
from rich import print
from tqdm import tqdm
from book_maker.utils import num_tokens_from_text, prompt_config_to_kwargs
from .base_loader import BaseBookLoader
from .helper import EPUBBookLoaderHelper, is_text_link, not_trans
class EPUBBookLoader(BaseBookLoader):
def __init__(
self,
epub_name,
model,
key,
resume,
language,
model_api_base=None,
is_test=False,
test_num=5,
prompt_config=None,
single_translate=False,
context_flag=False,
context_paragraph_limit=0,
temperature=1.0,
source_lang="auto",
parallel_workers=1,
):
self.epub_name = epub_name
self.new_epub = epub.EpubBook()
self.translate_model = model(
key,
language,
api_base=model_api_base,
context_flag=context_flag,
context_paragraph_limit=context_paragraph_limit,
temperature=temperature,
source_lang=source_lang,
**prompt_config_to_kwargs(prompt_config),
)
self.is_test = is_test
self.test_num = test_num
self.translate_tags = "p"
self.exclude_translate_tags = "sup"
self.allow_navigable_strings = False
self.accumulated_num = 1
self.translation_style = ""
self.context_flag = context_flag
self.helper = EPUBBookLoaderHelper(
self.translate_model,
self.accumulated_num,
self.translation_style,
self.context_flag,
)
self.retranslate = None
self.exclude_filelist = ""
self.only_filelist = ""
self.single_translate = single_translate
self.block_size = -1
self.batch_use_flag = False
self.batch_flag = False
self.parallel_workers = 1
self.enable_parallel = False
self._progress_lock = Lock()
self._translation_index = 0
self.set_parallel_workers(parallel_workers)
# monkey patch for # 173
def _write_items_patch(obj):
for item in obj.book.get_items():
if isinstance(item, epub.EpubNcx):
obj.out.writestr(
"%s/%s" % (obj.book.FOLDER_NAME, item.file_name), obj._get_ncx()
)
elif isinstance(item, epub.EpubNav):
obj.out.writestr(
"%s/%s" % (obj.book.FOLDER_NAME, item.file_name),
obj._get_nav(item),
)
elif item.manifest:
obj.out.writestr(
"%s/%s" % (obj.book.FOLDER_NAME, item.file_name), item.content
)
else:
obj.out.writestr("%s" % item.file_name, item.content)
def _check_deprecated(obj):
pass
epub.EpubWriter._write_items = _write_items_patch
epub.EpubReader._check_deprecated = _check_deprecated
try:
self.origin_book = epub.read_epub(self.epub_name)
except Exception:
# tricky monkey patch for #71 if you don't know why please check the issue and ignore this
# when upstream change will TODO fix this
def _load_spine(obj):
spine = obj.container.find("{%s}%s" % (epub.NAMESPACES["OPF"], "spine"))
obj.book.spine = [
(t.get("idref"), t.get("linear", "yes")) for t in spine
]
obj.book.set_direction(spine.get("page-progression-direction", None))
epub.EpubReader._load_spine = _load_spine
self.origin_book = epub.read_epub(self.epub_name)
self.p_to_save = []
self.resume = resume
self.bin_path = f"{Path(epub_name).parent}/.{Path(epub_name).stem}.temp.bin"
if self.resume:
self.load_state()
@staticmethod
def _is_special_text(text):
return (
text.isdigit()
or text.isspace()
or is_text_link(text)
or all(char in string.punctuation for char in text)
)
def _make_new_book(self, book):
new_book = epub.EpubBook()
allowed_ns = set(epub.NAMESPACES.keys()) | set(epub.NAMESPACES.values())
for namespace, metas in book.metadata.items():
# Only keep namespaces recognized by ebooklib
if namespace not in allowed_ns:
continue
if isinstance(metas, dict):
entries = (
(name, value, others)
for name, values in metas.items()
for value, others in (
(item if isinstance(item, tuple) else (item, None))
for item in values
)
)
else:
entries = metas
for entry in entries:
if not entry:
continue
if isinstance(entry, tuple):
if len(entry) == 3:
name, value, others = entry
elif len(entry) == 2:
name, value = entry
others = None
else:
continue
else:
# Unexpected metadata format; skip gracefully
continue
# `others` can be {} or None
if others:
new_book.add_metadata(namespace, name, value, others)
else:
new_book.add_metadata(namespace, name, value)
new_book.spine = book.spine
new_book.toc = self._fix_toc_uids(book.toc)
return new_book
def _fix_toc_uids(self, toc, counter=None):
"""Fix TOC items that have uid=None to prevent TypeError when writing NCX."""
if counter is None:
counter = [0] # Use list to allow mutation in nested calls
fixed_toc = []
for item in toc:
if isinstance(item, tuple):
# Section with sub-items: (Section, [sub-items])
section, sub_items = item
if hasattr(section, "uid") and section.uid is None:
section.uid = f"navpoint-{counter[0]}"
counter[0] += 1
fixed_sub_items = self._fix_toc_uids(sub_items, counter)
fixed_toc.append((section, fixed_sub_items))
elif hasattr(item, "uid"):
# Link or EpubHtml item
if item.uid is None:
item.uid = f"navpoint-{counter[0]}"
counter[0] += 1
fixed_toc.append(item)
else:
fixed_toc.append(item)
return fixed_toc
def _extract_paragraph(self, p):
for p_exclude in self.exclude_translate_tags.split(","):
# for issue #280
if type(p) is NavigableString:
continue
for pt in p.find_all(p_exclude):
pt.extract()
return p
def _process_paragraph(self, p, new_p, index, p_to_save_len, thread_safe=False):
if self.resume and index < p_to_save_len:
p.string = self.p_to_save[index]
new_p.string = self.p_to_save[
index
] # Fix: also update new_p to cached translation
else:
t_text = ""
if self.batch_flag:
self.translate_model.add_to_batch_translate_queue(index, new_p.text)
elif self.batch_use_flag:
t_text = self.translate_model.batch_translate(index)
else:
t_text = self.translate_model.translate(new_p.text)
if t_text is None:
raise RuntimeError(
"`t_text` is None: your translation model is not working as expected. Please check your translation model configuration."
)
if type(p) is NavigableString:
new_p = t_text
self.p_to_save.append(new_p)
else:
new_p.string = t_text
self.p_to_save.append(new_p.text)
self.helper.insert_trans(
p, new_p.string, self.translation_style, self.single_translate
)
index += 1
if thread_safe:
with self._progress_lock:
if index % 20 == 0:
self._save_progress()
else:
if index % 20 == 0:
self._save_progress()
return index
def _process_combined_paragraph(
self, p_block, index, p_to_save_len, thread_safe=False
):
text = []
for p in p_block:
if self.resume and index < p_to_save_len:
p.string = self.p_to_save[index]
else:
p_text = p.text.rstrip()
text.append(p_text)
if self.is_test and index >= self.test_num:
break
index += 1
if len(text) > 0:
translated_text = self.translate_model.translate("\n".join(text))
translated_text = translated_text.split("\n")
text_len = len(translated_text)
for i in range(text_len):
t = translated_text[i]
if i >= len(p_block):
p = p_block[-1]
else:
p = p_block[i]
if type(p) is NavigableString:
p = t
else:
p.string = t
self.helper.insert_trans(
p, p.string, self.translation_style, self.single_translate
)
if thread_safe:
with self._progress_lock:
self._save_progress()
else:
self._save_progress()
return index
def translate_paragraphs_acc(self, p_list, send_num):
count = 0
wait_p_list = []
for i in range(len(p_list)):
p = p_list[i]
print(f"translating {i}/{len(p_list)}")
temp_p = copy(p)
for p_exclude in self.exclude_translate_tags.split(","):
# for issue #280
if type(p) is NavigableString:
continue
for pt in temp_p.find_all(p_exclude):
pt.extract()
if any(
[not p.text, self._is_special_text(temp_p.text), not_trans(temp_p.text)]
):
if i == len(p_list) - 1:
self.helper.deal_old(wait_p_list, self.single_translate)
continue
length = num_tokens_from_text(temp_p.text)
if length > send_num:
self.helper.deal_new(p, wait_p_list, self.single_translate)
continue
if i == len(p_list) - 1:
if count + length < send_num:
wait_p_list.append(p)
self.helper.deal_old(wait_p_list, self.single_translate)
else:
self.helper.deal_new(p, wait_p_list, self.single_translate)
break
if count + length < send_num:
count += length
wait_p_list.append(p)
else:
self.helper.deal_old(wait_p_list, self.single_translate)
wait_p_list.append(p)
count = length
def get_item(self, book, name):
for item in book.get_items():
if item.file_name == name:
return item
def find_items_containing_string(self, book, search_string):
matching_items = []
for item in book.get_items_of_type(ITEM_DOCUMENT):
content = item.get_content()
soup = bs(content, "html.parser")
if search_string in soup.get_text():
matching_items.append(item)
return matching_items
def retranslate_book(self, index, p_to_save_len, pbar, trans_taglist, retranslate):
complete_book_name = retranslate[0]
fixname = retranslate[1]
fixstart = retranslate[2]
fixend = retranslate[3]
if fixend == "":
fixend = fixstart
name_fix = complete_book_name
complete_book = epub.read_epub(complete_book_name)
if fixname == "":
fixname = self.find_items_containing_string(complete_book, fixstart)[
0
].file_name
print(f"auto find fixname: {fixname}")
new_book = self._make_new_book(complete_book)
complete_item = self.get_item(complete_book, fixname)
if complete_item is None:
return
ori_item = self.get_item(self.origin_book, fixname)
if ori_item is None:
return
content_complete = complete_item.content
content_ori = ori_item.content
soup_complete = bs(content_complete, "html.parser")
soup_ori = bs(content_ori, "html.parser")
p_list_complete = soup_complete.findAll(trans_taglist)
p_list_ori = soup_ori.findAll(trans_taglist)
target = None
tagl = []
# extract from range
find_end = False
find_start = False
for tag in p_list_complete:
if find_end:
tagl.append(tag)
break
if fixend in tag.text:
find_end = True
if fixstart in tag.text:
find_start = True
if find_start:
if not target:
target = tag.previous_sibling
tagl.append(tag)
for t in tagl:
t.extract()
flag = False
extract_p_list_ori = []
for p in p_list_ori:
if fixstart in p.text:
flag = True
if flag:
extract_p_list_ori.append(p)
if fixend in p.text:
break
for t in extract_p_list_ori:
if target:
target.insert_after(t)
target = t
for item in complete_book.get_items():
if item.file_name != fixname:
new_book.add_item(item)
if soup_complete:
complete_item.content = soup_complete.encode()
index = self.process_item(
complete_item,
index,
p_to_save_len,
pbar,
new_book,
trans_taglist,
fixstart,
fixend,
)
epub.write_epub(f"{name_fix}", new_book, {})
def has_nest_child(self, element, trans_taglist):
if isinstance(element, Tag):
for child in element.children:
if child.name in trans_taglist:
return True
if self.has_nest_child(child, trans_taglist):
return True
return False
def filter_nest_list(self, p_list, trans_taglist):
filtered_list = [p for p in p_list if not self.has_nest_child(p, trans_taglist)]
return filtered_list
def process_item(
self,
item,
index,
p_to_save_len,
pbar,
new_book,
trans_taglist,
fixstart=None,
fixend=None,
):
if self.only_filelist != "" and item.file_name not in self.only_filelist.split(
","
):
return index
elif self.only_filelist == "" and item.file_name in self.exclude_filelist.split(
","
):
new_book.add_item(item)
return index
if not os.path.exists("log"):
os.makedirs("log")
content = item.content
soup = bs(content, "html.parser")
p_list = soup.findAll(trans_taglist)
p_list = self.filter_nest_list(p_list, trans_taglist)
if self.retranslate:
new_p_list = []
if fixstart is None or fixend is None:
return
start_append = False
for p in p_list:
text = p.get_text()
if fixstart in text or fixend in text or start_append:
start_append = True
new_p_list.append(p)
if fixend in text:
p_list = new_p_list
break
if self.allow_navigable_strings:
p_list.extend(soup.findAll(text=True))
send_num = self.accumulated_num
if send_num > 1:
with open("log/buglog.txt", "a") as f:
print(f"------------- {item.file_name} -------------", file=f)
print("------------------------------------------------------")
print(f"dealing {item.file_name} ...")
self.translate_paragraphs_acc(p_list, send_num)
else:
is_test_done = self.is_test and index > self.test_num
p_block = []
block_len = 0
for p in p_list:
if is_test_done:
break
if not p.text or self._is_special_text(p.text):
pbar.update(1)
continue
new_p = self._extract_paragraph(copy(p))
if self.single_translate and self.block_size > 0:
p_len = num_tokens_from_text(new_p.text)
block_len += p_len
if block_len > self.block_size:
index = self._process_combined_paragraph(
p_block, index, p_to_save_len, thread_safe=False
)
p_block = [p]
block_len = p_len
print()
else:
p_block.append(p)
else:
index = self._process_paragraph(
p, new_p, index, p_to_save_len, thread_safe=False
)
print()
# pbar.update(delta) not pbar.update(index)?
pbar.update(1)
if self.is_test and index >= self.test_num:
break
if self.single_translate and self.block_size > 0 and len(p_block) > 0:
index = self._process_combined_paragraph(
p_block, index, p_to_save_len, thread_safe=False
)
if soup:
item.content = soup.encode(encoding="utf-8")
new_book.add_item(item)
return index
def set_parallel_workers(self, workers):
"""Set number of parallel workers for chapter processing.
Args:
workers (int): Number of parallel workers. Will be automatically
optimized based on actual chapter count during processing.
"""
self.parallel_workers = max(1, workers)
self.enable_parallel = workers > 1
if workers > 8:
print(
f"⚠️ Warning: {workers} workers is quite high. Consider using 2-8 workers for optimal performance."
)
def _get_next_translation_index(self):
"""Thread-safe method to get next translation index."""
with self._progress_lock:
index = self._translation_index
self._translation_index += 1
return index
def _process_chapter_parallel(self, chapter_data):
"""Process a single chapter in parallel mode with proper accumulated_num handling."""
item, trans_taglist, p_to_save_len = chapter_data
chapter_result = {
"item": item,
"processed_content": None,
"success": False,
"error": None,
}
try:
# Create a chapter-specific translator instance to avoid context conflicts
# This ensures each chapter has its own independent context
thread_translator = self._create_chapter_translator()
content = item.content
soup = bs(content, "html.parser")
p_list = soup.findAll(trans_taglist)
p_list = self.filter_nest_list(p_list, trans_taglist)
if self.allow_navigable_strings:
p_list.extend(soup.findAll(text=True))
# Initialize chapter-specific context lists
chapter_context_list = []
chapter_translated_list = []
# Apply accumulated_num logic for this chapter independently
send_num = self.accumulated_num
if send_num > 1:
# Use accumulated translation logic for this chapter
self._translate_paragraphs_acc_parallel(
p_list,
send_num,
thread_translator,
chapter_context_list,
chapter_translated_list,
)
else:
# Process paragraphs individually for this chapter
for p in p_list:
if not p.text or self._is_special_text(p.text):
continue
new_p = self._extract_paragraph(copy(p))
index = self._get_next_translation_index()
if self.resume and index < p_to_save_len:
t_text = self.p_to_save[index]
else:
# Use chapter-specific context for translation
t_text = self._translate_with_chapter_context(
thread_translator,
new_p.text,
chapter_context_list,
chapter_translated_list,
)
t_text = "" if t_text is None else t_text
with self._progress_lock:
self.p_to_save.append(t_text)
if isinstance(p, NavigableString):
translated_node = NavigableString(t_text)
p.insert_after(translated_node)
if self.single_translate:
p.extract()
else:
self.helper.insert_trans(
p, t_text, self.translation_style, self.single_translate
)
with self._progress_lock:
if index % 20 == 0:
self._save_progress()
if soup:
chapter_result["processed_content"] = soup.encode(encoding="utf-8")
chapter_result["success"] = True
except Exception as e:
chapter_result["error"] = str(e)
print(f"Error processing chapter {item.file_name}: {e}")
return chapter_result
def _create_chapter_translator(self):
"""Create a translator instance for a specific chapter with independent context."""
# Return the main translator - we'll handle context at the chapter level
return self.translate_model
def _translate_with_chapter_context(
self, translator, text, chapter_context_list, chapter_translated_list
):
"""Translate text with chapter-specific context management."""
if not translator.context_flag:
return translator.translate(text)
# Temporarily replace global context with chapter context
original_context = getattr(translator, "context_list", [])
original_translated = getattr(translator, "context_translated_list", [])
try:
# Use chapter-specific context
translator.context_list = chapter_context_list.copy()
translator.context_translated_list = chapter_translated_list.copy()
# Perform translation
result = translator.translate(text)
# Update chapter context
chapter_context_list[:] = translator.context_list
chapter_translated_list[:] = translator.context_translated_list
return result
finally:
# Restore original context
translator.context_list = original_context
translator.context_translated_list = original_translated
def _translate_paragraphs_acc_parallel(
self,
p_list,
send_num,
translator,
chapter_context_list,
chapter_translated_list,
):
"""Apply accumulated_num logic for a single chapter in parallel mode with independent context."""
from book_maker.utils import num_tokens_from_text
from .helper import not_trans
count = 0
wait_p_list = []
# Create chapter-specific helper instance with context-aware translation
class ChapterHelper:
def __init__(
self, parent_loader, translator, context_list, translated_list
):
self.parent_loader = parent_loader
self.translator = translator
self.context_list = context_list
self.translated_list = translated_list
def translate_with_context(self, text):
return self.parent_loader._translate_with_chapter_context(
self.translator, text, self.context_list, self.translated_list
)
def deal_old(self, wait_p_list, single_translate):
if not wait_p_list:
return
# Use the same translate_list logic as sequential processing
# Create a temporary translator with chapter context
original_context = getattr(self.translator, "context_list", [])
original_translated = getattr(
self.translator, "context_translated_list", []
)
try:
# Set chapter context to the translator
self.translator.context_list = self.context_list.copy()
self.translator.context_translated_list = (
self.translated_list.copy()
)
# Call translate_list for consistent batch translation logic
result_txt_list = self.translator.translate_list(wait_p_list)
# Update chapter context from translator
self.context_list[:] = self.translator.context_list
self.translated_list[:] = self.translator.context_translated_list
# Apply translations using the same logic as helper.deal_old
for i in range(len(wait_p_list)):
if i < len(result_txt_list):
p = wait_p_list[i]
from .helper import shorter_result_link
self.parent_loader.helper.insert_trans(
p,
shorter_result_link(result_txt_list[i]),
self.parent_loader.translation_style,
single_translate,
)
finally:
# Restore original context
self.translator.context_list = original_context
self.translator.context_translated_list = original_translated
wait_p_list.clear()
def deal_new(self, p, wait_p_list, single_translate):
self.deal_old(wait_p_list, single_translate)
translation = self.translate_with_context(p.text)
self.parent_loader.helper.insert_trans(
p,
translation,
self.parent_loader.translation_style,
single_translate,
)
chapter_helper = ChapterHelper(
self, translator, chapter_context_list, chapter_translated_list
)
for i in range(len(p_list)):
p = p_list[i]
temp_p = copy(p)
for p_exclude in self.exclude_translate_tags.split(","):
if type(p) == NavigableString:
continue
for pt in temp_p.find_all(p_exclude):
pt.extract()
if any(
[not p.text, self._is_special_text(temp_p.text), not_trans(temp_p.text)]
):
if i == len(p_list) - 1:
chapter_helper.deal_old(wait_p_list, self.single_translate)
continue
length = num_tokens_from_text(temp_p.text)
if length > send_num:
chapter_helper.deal_new(p, wait_p_list, self.single_translate)
continue
if i == len(p_list) - 1:
if count + length < send_num:
wait_p_list.append(p)
chapter_helper.deal_old(wait_p_list, self.single_translate)
else:
chapter_helper.deal_new(p, wait_p_list, self.single_translate)
break
if count + length < send_num:
count += length
wait_p_list.append(p)
else:
chapter_helper.deal_old(wait_p_list, self.single_translate)
wait_p_list.append(p)
count = length
def batch_init_then_wait(self):
name, _ = os.path.splitext(self.epub_name)
if self.batch_flag or self.batch_use_flag:
self.translate_model.batch_init(name)
if self.batch_use_flag:
start_time = time.time()
while not self.translate_model.is_completed_batch():
print("Batch translation is not completed yet")
time.sleep(2)
if time.time() - start_time > 300: # 5 minutes
raise Exception("Batch translation timed out after 5 minutes")
def make_bilingual_book(self):
self.helper = EPUBBookLoaderHelper(
self.translate_model,
self.accumulated_num,
self.translation_style,
self.context_flag,
)
self.batch_init_then_wait()
new_book = self._make_new_book(self.origin_book)
all_items = list(self.origin_book.get_items())
trans_taglist = self.translate_tags.split(",")
all_p_length = sum(
(
0
if (
(i.get_type() != ITEM_DOCUMENT)
or (i.file_name in self.exclude_filelist.split(","))
or (
self.only_filelist
and i.file_name not in self.only_filelist.split(",")
)
)
else len(bs(i.content, "html.parser").findAll(trans_taglist))
)
for i in all_items
)
all_p_length += self.allow_navigable_strings * sum(
(
0
if (
(i.get_type() != ITEM_DOCUMENT)
or (i.file_name in self.exclude_filelist.split(","))
or (
self.only_filelist
and i.file_name not in self.only_filelist.split(",")
)
)
else len(bs(i.content, "html.parser").findAll(text=True))
)
for i in all_items
)
pbar = tqdm(total=self.test_num) if self.is_test else tqdm(total=all_p_length)
print()
index = 0
p_to_save_len = len(self.p_to_save)
try:
if self.retranslate:
self.retranslate_book(
index, p_to_save_len, pbar, trans_taglist, self.retranslate
)
exit(0)
# Add the things that don't need to be translated first, so that you can see the img after the interruption
for item in self.origin_book.get_items():
if item.get_type() != ITEM_DOCUMENT:
new_book.add_item(item)
document_items = list(self.origin_book.get_items_of_type(ITEM_DOCUMENT))
if self.enable_parallel and len(document_items) > 1:
# Optimize worker count: no point having more workers than chapters
effective_workers = min(self.parallel_workers, len(document_items))
# Parallel processing with proper accumulated_num handling
print(f"🚀 Parallel processing: {len(document_items)} chapters")
if effective_workers < self.parallel_workers:
print(
f"📊 Optimized workers: {effective_workers} (reduced from {self.parallel_workers})"
)
else:
print(f"📊 Using {effective_workers} workers")
if self.accumulated_num > 1:
print(
f"📝 Each chapter applies accumulated_num={self.accumulated_num} independently"
)
if self.context_flag:
print(
f"🔗 Context enabled: each chapter maintains independent context (limit={self.translate_model.context_paragraph_limit})"
)
else:
print(f"🚫 Context disabled for this translation")
# Create a simpler progress bar for parallel processing
pbar.close() # Close the original progress bar
chapter_pbar = tqdm(
total=len(document_items), desc="Chapters", unit="ch"
)
chapter_data_list = [
(item, trans_taglist, p_to_save_len) for item in document_items
]
with ThreadPoolExecutor(max_workers=effective_workers) as executor:
future_to_item = {
executor.submit(
self._process_chapter_parallel, chapter_data
): chapter_data[0]
for chapter_data in chapter_data_list
}
for future in as_completed(future_to_item):
item = future_to_item[future]
try:
result = future.result()
if result["success"] and result["processed_content"]:
item.content = result["processed_content"]
new_book.add_item(item)
chapter_pbar.update(1)
chapter_pbar.set_postfix_str(
f"Latest: {item.file_name[:20]}..."
)
except Exception as e:
print(f"❌ Error processing {item.file_name}: {e}")
new_book.add_item(item)
chapter_pbar.update(1)
chapter_pbar.close()
print(f"✅ Completed all {len(document_items)} chapters")
else:
# Sequential processing (original behavior or single chapter)
if len(document_items) == 1 and self.enable_parallel:
print(f"📄 Single chapter detected - using sequential processing")
for item in document_items:
index = self.process_item(
item, index, p_to_save_len, pbar, new_book, trans_taglist
)
if self.accumulated_num > 1:
name, _ = os.path.splitext(self.epub_name)
epub.write_epub(f"{name}_bilingual.epub", new_book, {})
name, _ = os.path.splitext(self.epub_name)
if self.batch_flag:
self.translate_model.batch()
else:
epub.write_epub(f"{name}_bilingual.epub", new_book, {})
if self.accumulated_num == 1:
pbar.close()
except KeyboardInterrupt as e:
print(e)
if self.accumulated_num == 1:
print("you can resume it next time")
self._save_progress()
self._save_temp_book()
sys.exit(0)
except Exception:
traceback.print_exc()
sys.exit(0)
def load_state(self):
try:
with open(self.bin_path, "rb") as f:
self.p_to_save = pickle.load(f)
except Exception:
raise Exception("can not load resume file")
def _save_temp_book(self):
# TODO refactor this logic
origin_book_temp = epub.read_epub(self.epub_name)
new_temp_book = self._make_new_book(origin_book_temp)
p_to_save_len = len(self.p_to_save)
trans_taglist = self.translate_tags.split(",")
index = 0
try:
for item in origin_book_temp.get_items():
if item.get_type() == ITEM_DOCUMENT:
content = item.content
soup = bs(content, "html.parser")
p_list = soup.findAll(trans_taglist)
if self.allow_navigable_strings:
p_list.extend(soup.findAll(text=True))
for p in p_list:
if not p.text or self._is_special_text(p.text):
continue
# TODO banch of p to translate then combine
# PR welcome here
if index < p_to_save_len:
new_p = copy(p)
if type(p) is NavigableString:
new_p = self.p_to_save[index]
else:
new_p.string = self.p_to_save[index]
self.helper.insert_trans(
p,
new_p.string,
self.translation_style,
self.single_translate,
)
index += 1
else:
break
# for save temp book
if soup:
item.content = soup.encode()
new_temp_book.add_item(item)
name, _ = os.path.splitext(self.epub_name)
epub.write_epub(f"{name}_bilingual_temp.epub", new_temp_book, {})
except Exception as e:
# TODO handle it
print(e)
def _save_progress(self):
try:
with open(self.bin_path, "wb") as f:
pickle.dump(self.p_to_save, f)
except Exception:
raise Exception("can not save resume file")
================================================
FILE: book_maker/loader/helper.py
================================================
import re
import backoff
import logging
from copy import copy
logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger(__name__)
class EPUBBookLoaderHelper:
def __init__(
self, translate_model, accumulated_num, translation_style, context_flag
):
self.translate_model = translate_model
self.accumulated_num = accumulated_num
self.translation_style = translation_style
self.context_flag = context_flag
def insert_trans(self, p, text, translation_style="", single_translate=False):
if text is None:
text = ""
if (
p.string is not None
and p.string.replace(" ", "").strip() == text.replace(" ", "").strip()
):
return
new_p = copy(p)
new_p.string = text
if translation_style != "":
new_p["style"] = translation_style
p.insert_after(new_p)
if single_translate:
p.extract()
@backoff.on_exception(
backoff.expo,
Exception,
on_backoff=lambda details: logger.warning(f"retry backoff: {details}"),
on_giveup=lambda details: logger.warning(f"retry abort: {details}"),
jitter=None,
)
def translate_with_backoff(self, text, context_flag=False):
return self.translate_model.translate(text, context_flag)
def deal_new(self, p, wait_p_list, single_translate=False):
self.deal_old(wait_p_list, single_translate, self.context_flag)
self.insert_trans(
p,
shorter_result_link(self.translate_with_backoff(p.text, self.context_flag)),
self.translation_style,
single_translate,
)
def deal_old(self, wait_p_list, single_translate=False, context_flag=False):
if not wait_p_list:
return
result_txt_list = self.translate_model.translate_list(wait_p_list)
for i in range(len(wait_p_list)):
if i < len(result_txt_list):
p = wait_p_list[i]
self.insert_trans(
p,
shorter_result_link(result_txt_list[i]),
self.translation_style,
single_translate,
)
wait_p_list.clear()
url_pattern = r"(http[s]?://|www\.)+(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"
def is_text_link(text):
return bool(re.compile(url_pattern).match(text.strip()))
def is_text_tail_link(text, num=80):
text = text.strip()
pattern = r".*" + url_pattern + r"$"
return bool(re.compile(pattern).match(text)) and len(text) < num
def shorter_result_link(text, num=20):
match = re.search(url_pattern, text)
if not match or len(match.group()) < num:
return text
return re.compile(url_pattern).sub("...", text)
def is_text_source(text):
return text.strip().startswith("Source: ")
def is_text_list(text, num=80):
text = text.strip()
return re.match(r"^Listing\s*\d+", text) and len(text) < num
def is_text_figure(text, num=80):
text = text.strip()
return re.match(r"^Figure\s*\d+", text) and len(text) < num
def is_text_digit_and_space(s):
for c in s:
if not c.isdigit() and not c.isspace():
return False
return True
def is_text_isbn(s):
pattern = r"^[Ee]?ISBN\s*\d[\d\s]*$"
return bool(re.match(pattern, s))
def not_trans(s):
return any(
[
is_text_link(s),
is_text_tail_link(s),
is_text_source(s),
is_text_list(s),
is_text_figure(s),
is_text_digit_and_space(s),
is_text_isbn(s),
]
)
================================================
FILE: book_maker/loader/md_loader.py
================================================
import sys
from pathlib import Path
from book_maker.utils import prompt_config_to_kwargs
from .base_loader import BaseBookLoader
class MarkdownBookLoader(BaseBookLoader):
def __init__(
self,
md_name,
model,
key,
resume,
language,
model_api_base=None,
is_test=False,
test_num=5,
prompt_config=None,
single_translate=False,
context_flag=False,
context_paragraph_limit=0,
temperature=1.0,
source_lang="auto",
) -> None:
self.md_name = md_name
self.translate_model = model(
key,
language,
api_base=model_api_base,
temperature=temperature,
source_lang=source_lang,
**prompt_config_to_kwargs(prompt_config),
)
self.is_test = is_test
self.p_to_save = []
self.bilingual_result = []
self.bilingual_temp_result = []
self.test_num = test_num
self.batch_size = 10
self.single_translate = single_translate
self.md_paragraphs = []
try:
with open(f"{md_name}", encoding="utf-8") as f:
self.origin_book = f.read().splitlines()
except Exception as e:
raise Exception("can not load file") from e
self.resume = resume
self.bin_path = f"{Path(md_name).parent}/.{Path(md_name).stem}.temp.bin"
if self.resume:
self.load_state()
self.process_markdown_content()
def process_markdown_content(self):
"""将原始内容处理成 markdown 段落"""
current_paragraph = []
for line in self.origin_book:
# 如果是空行且当前段落不为空,保存当前段落
if not line.strip() and current_paragraph:
self.md_paragraphs.append("\n".join(current_paragraph))
current_paragraph = []
# 如果是标题行,单独作为一个段落
elif line.strip().startswith("#"):
if current_paragraph:
self.md_paragraphs.append("\n".join(current_paragraph))
current_paragraph = []
self.md_paragraphs.append(line)
# 其他情况,添加到当前段落
else:
current_paragraph.append(line)
# 处理最后一个段落
if current_paragraph:
self.md_paragraphs.append("\n".join(current_paragraph))
@staticmethod
def _is_special_text(text):
return text.isdigit() or text.isspace() or len(text) == 0
def _make_new_book(self, book):
pass
def make_bilingual_book(self):
index = 0
p_to_save_len = len(self.p_to_save)
try:
sliced_list = [
self.md_paragraphs[i : i + self.batch_size]
for i in range(0, len(self.md_paragraphs), self.batch_size)
]
for paragraphs in sliced_list:
batch_text = "\n\n".join(paragraphs)
if self._is_special_text(batch_text):
continue
if not self.resume or index >= p_to_save_len:
try:
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
temp = self.translate_model.translate(batch_text)
break
except AttributeError as ae:
print(f"翻译出错: {ae}")
retry_count += 1
if retry_count == max_retries:
raise Exception("翻译模型初始化失败") from ae
except Exception as e:
print(f"翻译过程中出错: {e}")
raise Exception("翻译过程中出现错误") from e
self.p_to_save.append(temp)
if not self.single_translate:
self.bilingual_result.append(batch_text)
self.bilingual_result.append(temp)
index += self.batch_size
if self.is_test and index > self.test_num:
break
self.save_file(
f"{Path(self.md_name).parent}/{Path(self.md_name).stem}_bilingual.md",
self.bilingual_result,
)
except (KeyboardInterrupt, Exception) as e:
print(f"发生错误: {e}")
print("程序将保存进度,您可以稍后继续")
self._save_progress()
self._save_temp_book()
sys.exit(1) # 使用非零退出码表示错误
def _save_temp_book(self):
index = 0
sliced_list = [
self.origin_book[i : i + self.batch_size]
for i in range(0, len(self.origin_book), self.batch_size)
]
for i in range(len(sliced_list)):
batch_text = "".join(sliced_list[i])
self.bilingual_temp_result.append(batch_text)
if self._is_special_text(self.origin_book[i]):
continue
if index < len(self.p_to_save):
self.bilingual_temp_result.append(self.p_to_save[index])
index += 1
self.save_file(
f"{Path(self.md_name).parent}/{Path(self.md_name).stem}_bilingual_temp.txt",
self.bilingual_temp_result,
)
def _save_progress(self):
try:
with open(self.bin_path, "w", encoding="utf-8") as f:
f.write("\n".join(self.p_to_save))
except Exception as e:
raise Exception("can not save resume file") from e
def load_state(self):
try:
with open(self.bin_path, encoding="utf-8") as f:
self.p_to_save = f.read().splitlines()
except Exception as e:
raise Exception("can not load resume file") from e
def save_file(self, book_path, content):
try:
with open(book_path, "w", encoding="utf-8") as f:
f.write("\n".join(content))
except Exception as e:
raise Exception("can not save file") from e
================================================
FILE: book_maker/loader/pdf_loader.py
================================================
import sys
from pathlib import Path
from book_maker.utils import prompt_config_to_kwargs
from .base_loader import BaseBookLoader
import fitz
from ebooklib import epub
class PDFBookLoader(BaseBookLoader):
def __init__(
self,
pdf_name,
model,
key,
resume,
language,
model_api_base=None,
is_test=False,
test_num=5,
prompt_config=None,
single_translate=False,
context_flag=False,
context_paragraph_limit=0,
temperature=1.0,
source_lang="auto",
parallel_workers=1,
) -> None:
if fitz is None:
raise Exception("PyMuPDF (fitz) is required to use PDF loader")
self.pdf_name = pdf_name
self.translate_model = model(
key,
language,
api_base=model_api_base,
temperature=temperature,
source_lang=source_lang,
**prompt_config_to_kwargs(prompt_config),
)
self.is_test = is_test
self.p_to_save = []
self.bilingual_result = []
self.bilingual_temp_result = []
self.test_num = test_num
self.batch_size = 10
self.single_translate = single_translate
self.parallel_workers = max(1, parallel_workers)
try:
doc = fitz.open(self.pdf_name)
lines = []
for page in doc:
text = page.get_text("text")
if not text:
continue
lines.extend(text.splitlines())
self.origin_book = lines
except Exception as e:
raise Exception("can not load file") from e
self.resume = resume
self.bin_path = f"{Path(pdf_name).parent}/.{Path(pdf_name).stem}.temp.bin"
if self.resume:
self.load_state()
def _make_new_book(self, book):
pass
def _try_create_epub(self):
"""Try to create an EPUB file from translated content.
The EPUB is created from the `self.bilingual_result` list which alternates
original and translated strings. If EPUB creation fails for any reason,
this function will log the error and leave the TXT fallback intact.
"""
if epub is None:
# ebooklib not installed; skip EPUB generation
return False
if not self.bilingual_result:
return False
try:
book = epub.EpubBook()
title = Path(self.pdf_name).stem
# Minimal metadata
try:
book.set_identifier(title)
book.set_title(title)
book.set_language(
self.translate_model.language
if hasattr(self.translate_model, "language")
else "en"
)
except Exception:
# be tolerant about metadata API differences
pass
chapters = []
# build chapters from bilingual_result (pairs)
for i in range(0, len(self.bilingual_result), 2):
orig = self.bilingual_result[i]
trans = (
self.bilingual_result[i + 1]
if i + 1 < len(self.bilingual_result)
else ""
)
# basic html content: original then translated
content = ""
if orig:
content += (
'
`. Use `--translate-tags` to specify tags need for translation. Use comma to separate multiple tags. For example: `--translate-tags h1,h2,h3,p,div`
bbook_maker --book_name test_books/animal_farm.epub --openai_key ${openai_key} --translate-tags div,p
If you want to translate strings in an e-book that aren't labeled with any tags, you can use the `--allow_navigable_strings` parameter. This will add the strings to the translation queue.
**Note that it's best to look for e-books that are more standardized if possible.**
## e-reader
Use `--book_from` option to specify e-reader type (Now only `kobo` is available), and use `--device_path` to specify the mounting point.
# Translate books download from Rakuten Kobo on kobo e-reader
bbook_maker --book_from kobo --device_path /tmp/kobo
================================================
FILE: docs/cmd.md
================================================
# Command Line Options
## Test translate
`--test`
Use this option to preview the result if you haven't paid for the service or just want to test. Note that there is a limit and it may take some time.
```sh
bbook_maker --book_name test_books/Lex_Fridman_episode_322.srt --openai_key ${openai_key} --test
```
```sh
bbook_maker --book_name test_books/animal_farm.epub --openai_key ${openai_key} --test --language zh-hans
```
`--test_num `
Use this option to set how many paragraph you want to translate for testing. Default is 10.
## Resume
`--resume`
Use this option to manually resume the process after an interruption.
## Retranslate (epub only)
`--retranslate `
If a file in epub is not translated well, it supports to re-translate part of epub separately.
This option take 4 arguments: `translated_filepath`, `file_name_in_epub`, `start_str`, `end_str`. `end_str` is optional.
- Retranslate from start_str to end_str's tag:
bbook_maker --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' 'index_split_002.html' 'in spite of the present book shortage which' 'This kind of thing is not a good symptom. Obviously'
- Retranslate start_str's tag:
bbook_maker --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' 'index_split_002.html' 'in spite of the present book shortage which'
- Retranslate start_str's tag, auto find filename:
bbook_maker --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' '' 'in spite of the present book shortage which'
**Warning:**
**It deletes from the tag at start_str of the finished book to the next tag at end_str, and then re-translates.**
**Therefore, please make sure that the next tag of end_str is the translated content. (If end_str is not provided, the next label of start_str is guaranteed to be the translated content.) There can be missing translations between the two strings, but if end_str is not translated, there will be problems.**
## Customize output style (epub only)
`--translation_style `
Support changing the output style of epub files.
bbook_maker --book_name test_books/animal_farm.epub --translation_style "color: #4a4a4a; font-style: normal; background-color: #f7f7f7; padding: 5px; margin: 10px 0; border-radius: 5px;"

## Proxy
`--proxy `
Use this option to specify proxy server for internet access. Enter a string such as `http://127.0.0.1:7890` .
## API base
`--api_base `
If you want to change api_base like using Cloudflare Workers, use this option to support it.
bbook_maker --book_name 'animal_farm.epub' --openai_key sk-XXXXX --api_base 'https://xxxxx/v1'
**Note: the api url should be '`https://xxxx/v1`'. Quotation marks are required.**
## Microsoft Azure Endpoints
`--api_base ` `--deployment_id `
You can use the api endpoint provided from Microsoft.
bbook_maker --book_name 'animal_farm.epub' --openai_key XXXXX --api_base 'https://example-endpoint.openai.azure.com' --deployment_id 'deployment-name'
**Note : Current only support chatgptapi model for deployment_id. And `api_base` must be provided when using `deployment_id`. You can check [here](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal) for more information about `deployment_id`.**
## Batch size (txt only)
`--batch_size`
Use this parameter to specify the number of lines for batch translation. Default is 10. (Currently only effective for txt files).
```sh
python3 make_book.py --book_name test_books/the_little_prince.txt --test --batch_size 20
```
## Accumulated Num
`--accumulated_num `
Wait for how many tokens have been accumulated before starting the translation. gpt3.5 limits the total_token to 4090.
For example, if you use --accumulated_num 1600, maybe openai will
output 2200 tokens and maybe 200 tokens for other messages in the system messages user messages. 1600+2200+200=4000, so you are close to the limit.
You have to choose your own
value, there is no way to tell if the limit is reached before sending request.
================================================
FILE: docs/disclaimer.md
================================================
Disclaimer:
1. The purpose of this project, bilingual_book_maker, is to assist users in creating multilingual versions of epub files and books. It is only applicable to books that have entered the public domain and is not intended for use with copyrighted material. We strongly advise users to read the copyright information carefully before using this project and to comply with relevant laws and regulations in order to protect their own and others' rights.
2. In no event shall the authors or developers be liable for any loss or damage caused by the use of this project. Users assume all risks associated with the use of this project. Users must confirm that they have obtained permission from the original copyright holder or used open source EPUB files before using this project to avoid potential copyright risks.
If you have any concerns or suggestions about the use of this project, please contact us through the issues section.
免责声明:
1. 该项目设计目的是为了帮助用户制作多语言版本的epub文件和图书,仅适用于进入公共版权领域书籍,不适用于有版权的书籍。我们强烈建议用户在使用该项目时仔细阅读其版权信息并遵守相关法律和规定,以保护自己和他人的权益。
2. 在任何情况下,作者和开发者不对因使用该项目而导致的任何损失或损害承担任何责任。使用该项目的风险由用户自行承担。用户必须在使用该项目之前,确认其已获得了原著作权人的许可或使用了公开可用的开源EPUB文件,以避免可能存在的版权风险。
如果您对该项目的使用有任何疑虑或建议,请通过 issues 与我们联系。
================================================
FILE: docs/env_settings.md
================================================
# Environment Settings
You can also write information into env to skip some options.
## Model keys
```
# Set env BBM_OPENAI_API_KEY to ignore option --openai_key
export BBM_OPENAI_API_KEY=${your_api_key}
# Set env BBM_CAIYUN_API_KEY to ignore option --caiyun_key
export BBM_CAIYUN_API_KEY=${your_api_key}
```
================================================
FILE: docs/index.md
================================================
# bilingual book maker
The `bilingual_book_maker` is an AI translation tool that uses ChatGPT to assist users in creating multi-language versions of epub/txt files and books.
This tool is exclusively designed for translating epub books that have entered the public domain and is not intended for copyrighted works. Before using this tool, please review the project's **[disclaimer](disclaimer.md)**.
================================================
FILE: docs/installation.md
================================================
# Installation
## pip
bilingual_book_maker has been published as a [Python package](https://pypi.org/project/bbook-maker/) and can be install by `pip`. (Recommend in a virtual environment.)
```sh
pip install -U bbook_maker
```
## git
You can also install from github if you want to use the latest version.
```sh
git clone git@github.com:yihong0618/bilingual_book_maker.git
pip install .
```
================================================
FILE: docs/model_lang.md
================================================
# Model and Languages
## Models
`-m, --model `
Currently `bbook_maker` supports these models: `chatgptapi` , `gpt3` , `google` , `caiyun` , `deepl` , `deeplfree` , `gpt4` , `gpt4omini` , `gpt5mini` , `o1-preview` , `o1` , `o1-mini` , `o3-mini` , `claude` , `customapi`.
Default model is `chatgptapi` .
### OPENAI models
There are several models you can choose from.
* gpt3
bbook_maker --book_name test_books/animal_farm.epub --model gpt3 --openai_key ${openai_key}
* chatgpiapi
`chatgptapi` is [GPT-3.5-turbo](https://openai.com/blog/introducing-chatgpt-and-whisper-apis), which is used by ChatGPT currently.
bbook_maker --book_name test_books/animal_farm.epub --model chatgptapi --openai_key ${openai_key}
* gpt4
bbook_maker --book_name test_books/animal_farm.epub --model gpt4 --openai_key ${openai_key}
If using `gpt4` , you can add `--use_context` to add a context paragraph to each passage sent to the model for translation.
bbook_maker --book_name test_books/animal_farm.epub --model gpt4 --openai_key ${openai_key} --use_context
The option `--use_context` prompts the GPT4 model to create a one-paragraph summary.
If it is the beginning of the translation, it will summarize the entire passage sent (the size depending on `--accumulated_num` ).
If it has any proceeding passage, it will amend the summary to include details from the most recent passage, creating a running one-paragraph context payload of the important details of the entire translated work, which improves consistency of flow and tone of each translation.
* gpt5mini
`gpt5mini` uses the `gpt-5-mini` model.
bbook_maker --book_name test_books/animal_farm.epub --model gpt5mini --openai_key ${openai_key}
**Note 1: Use `--openai_key` option to specify OpenAI API key. If you have multiple keys, separate them by commas (xxx, xxx, xxx) to reduce errors caused by API call limits.**
**Note 2: You can just set the environment variable `BBM_OPENAI_API_KEY` instead the openai_key. See [Environment setting](settings.md).**
### CAIYUN
Using Caiyun model to translate. The api currently only support:
1. Simplified Chinese <-> English
2. Simplified Chinese <-> Japanese
The official Caiyun has provided a test token (3975l6lr5pcbvidl6jl2). You can apply your own token by following this [tutorial].(https://bobtranslate.com/service/translate/caiyun.html)
bbook_maker --model caiyun --caiyun_key 3975l6lr5pcbvidl6jl2 --book_name test_books/animal_farm.epub
### DEEPL
There are two models you can choose from.
* deepl: [DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator).
Need to pay to get the token. Use `--model deepl --deepl_key ${deepl_key}`
bbook_maker --book_name test_books/animal_farm.epub --model deepl --deepl_key ${deepl_key}
* deeplfree: DeepL free model
bbook_maker --book_name test_books/animal_farm.epub --model deeplfree
### Claude
Support [Claude](https://console.anthropic.com/docs) model. Use `--model claude --claude_key ${claude_key}` .
bbook_maker --book_name test_books/animal_farm.epub --model claude --claude_key ${claude_key}
### Custom API
Support CustomAPI model. Use `--model customapi --custom_api ${custom_api}` .
bbook_maker --book_name test_books/animal_farm.epub --model customapi --custom_api ${custom_api}
### Google
Support google model. Use `--model google`
## Languages
`--language `
Set target languages. All models except for `caiyun` supports lots of languages. You can use `bbook_maker --help` to check available languages. Default target language is `"Simplified Chinese"` .
```sh
bbook_maker --book_name test_books/animal_farm.epub --model chatgptapi --openai_key ${openai_key} --language ja
```
```sh
bbook_maker --book_name test_books/animal_farm.epub --model chatgptapi --openai_key ${openai_key} --language "Simplified Chinese"
```
================================================
FILE: docs/prompt.md
================================================
# Tweak the prompt
To tweak the prompt, use the `--prompt` parameter. Valid placeholders for the `user` role template include `{text}` and `{language}`. It supports a few ways to configure the prompt:
- If you don't need to set the `system` role content, you can simply set it up like this: `--prompt "Translate {text} to {language}."` or `--prompt prompt_template_sample.txt`
# prompt_template_sample.txt
Translate the given text to {language}. Be faithful or accurate in translation. Make the translation readable or intelligible. Be elegant or natural in translation. If the text cannot be translated, return the original text as is. Do not translate person's name. Do not add any additional text in the translation. The text to be translated is:
{text}
- If you need to set the `system` role content, you can use the following format: `--prompt '{"user":"Translate {text} to {language}", "system": "You are a professional translator."}'` or `--prompt prompt_template_sample.json`
# prompt_template_sample.json
{
"system": "You are a professional translator.",
"user": "Translate the given text to {language}. Be faithful or accurate in translation. Make the translation readable or intelligible. Be elegant or natural in translation. If the text cannot be translated, return the original text as is. Do not translate person's name. Do not add any additional text in the translation. The text to be translated is:\n{text}"
}
You can also set the `user` and `system` role prompt by setting environment variables: `BBM_CHATGPTAPI_USER_MSG_TEMPLATE` and `BBM_CHATGPTAPI_SYS_MSG`.
- You can now use PromptDown format (`.md` files) for more structured prompts: `--prompt prompt_md.prompt.md`
# Translation Prompt
## System Message
You are a professional translator who specializes in accurate translations.
## Conversation
| Role | Content |
|-------|------------------------------------------|
| User | Please translate the following text into {language}:\n\n{text} |
# OR using Developer Message (for newer AI models)
# Translation Prompt
## Developer Message
You are a professional translator who specializes in accurate translations.
## Conversation
| Role | Content |
|-------|------------------------------------------|
| User | Please translate the following text into {language}:\n\n{text} |
## Examples
```sh
python3 make_book.py --book_name test_books/animal_farm.epub --prompt prompt_template_sample.txt
# or
python3 make_book.py --book_name test_books/animal_farm.epub --prompt prompt_template_sample.json
# or
python3 make_book.py --book_name test_books/animal_farm.epub --prompt "Please translate \`{text}\` to {language}"
```
================================================
FILE: docs/quickstart.md
================================================
# QuickStart
After successfully install the package, you can see `bbook-maker` is in the output of `pip list`.
## Preparation
1. ChatGPT or OpenAI [token](https://platform.openai.com/account/api-keys)
2. epub/txt books
3. Environment with internet access or proxy
4. Python 3.8+
## Use
You can use by command `bbook_maker`. A sample book, `test_books/animal_farm.epub`, is provided for testing purposes.
```sh
bbook_maker --book_name ${path of a book} --openai_key ${openai_key}
# Example
bbook_maker --book_name test_books/animal_farm.epub --openai_key ${openai_key}
```
Or, you can use the [script](https://github.com/yihong0618/bilingual_book_maker/blob/main/make_book.py) provided by repository.
```sh
python3 make_book.py --book_name ${path of a book} --openai_key ${openai_key}
# Example
python3 make_book.py --book_name test_books/animal_farm.epub --openai_key ${openai_key}
```
Once the translation is complete, a bilingual book named `${book_name}_bilingual.epub` would be generated.
**Note: If there are any errors or you wish to interrupt the translation by pressing `CTRL+C`. A book named `${book_name}_bilingual_temp.epub` would be generated. You can simply rename it to any desired name.**
================================================
FILE: make_book.py
================================================
from book_maker.cli import main
if __name__ == "__main__":
main()
================================================
FILE: mkdocs.yml
================================================
site_name: bilingual book maker
theme:
name: material
features:
- navigation.tabs
- navigation.tabs.sticky
- content.code.copy
nav:
- Home : index.md
- Getting started:
- Installation: installation.md
- QuickStart: quickstart.md
- Usage:
- Model and languages: model_lang.md
- Command line options: cmd.md
- Translate from different source: book_source.md
- Environment setting: env_settings.md
- Tweak the prompt: prompt.md
- Disclaimer: disclaimer.md
================================================
FILE: prompt_md.json
================================================
{
"system": "You are a highly skilled translator responsible for translating the content of books in Markdown format from English into Chinese.",
"user": "## Strategies\nYou will follow a three-step translation process:\n### 1. Translate the input content from English into Chinese, respect the intention of the original text, keep the original Markdown format unchanged, and do not delete or omit any content, nor add additional explanations or remarks.\n### 2. Read the original text and the translation carefully, and then put forward constructive criticism and helpful suggestions to improve the translation. The final style and tone of the translation should conform to the Chinese language style.\nYou must strictly follow the rules below.\n- Never change the Markdown markup structure. Don't add or remove links. Do not change any URL.\n- Never touch or change the contents of code blocks even if they appear to have a bug.\n- Always preserve the original line breaks. Do not add or remove blank lines.\n- Never touch any permalink at the end of each heading.\n- Never touch HTML-like tags such as ``.\nWhen writing suggestions, pay attention to whether there are ways to improve the translation in terms of:\n- Accuracy (by correcting errors such as additions, mistranslations, omissions or untranslated text).\n- Fluency (by applying the rules of Chinese grammar, spelling and punctuation, and ensuring there is no unnecessary repetition).\n- Conciseness and abbreviation (please appropriately simplify and abbreviate the translation result while keeping the original meaning unchanged to avoid the translation being too lengthy).\n### 3. Based on the results of steps 1 and 2, refine and polish the translation, and do not add additional explanations or remarks.\n## Output\nFor each step of the translation process, output the results within the appropriate XML tags:\n\n[Insert your initial translation here.]\n\n\n[Insert your reflection on the translation and put forward specific here, useful and constructive suggestions to improve the translation. Each suggestion should target a specific part of the translation.]\n\n\n[Insert your refined and polished translation here.]\n\n## Input\nThe following is the content of the book that needs to be translated within the tag:\n{text}"
}
================================================
FILE: prompt_md.prompt.md
================================================
# Translation Prompt
## Developer Message
You are a professional translator who specializes in accurate, natural-sounding translations that preserve the original meaning, tone, and style of the text.
## Conversation
| Role | Content |
|-------|---------------------------------------------------------------------------|
| User | Please translate the following text into {language}:\n\n{text} |
================================================
FILE: prompt_template_sample.json
================================================
{
"system": "You are a highly skilled academic translator. Please complete the translation task according to the following instructions and provide only the final polished translation.",
"user": "## Strategies\nYou will follow a three-step translation process:\n### Step.1 Initial Direct Translation: Translate the content from English to Chinese sentence by sentence, respecting the original intent without deleting, omitting, or adding any extra explanations or notes.\n ### Step.2 Reflection and Revision: Carefully review both the input content and the initial direct translation from Step 1. Check if the translation conveys the original meaning, if the grammatical structure is correct, if word choices are appropriate, and if there are any ambiguities or polysemous words. The final style and tone should conform to Chinese language conventions. \nYou must strictly follow the rules below.\n- Don't add or remove links. Do not change any URL.\n- Do not translate the reference list.\n- Never touch,change or translate the mathematical formulas.\n- Never touch,change or translate the contents of code blocks even if they appear to have a bug.\n- Always preserve the original line breaks. Do not add or remove blank lines.\nProvide constructive criticism and helpful suggestions to improve: \n- translation accuracy (correct additions, mistranslations, omissions, or untranslated text errors),\n- fluency (apply Chinese grammar, spelling, and punctuation rules, and ensure no unnecessary repetition), \n- conciseness (streamline the translation results while maintaining the original meaning, avoiding wordiness).\n ### Step.3 Polish and Optimize: Based on the results from Steps 1 and 2, refine and polish the translation, ensuring the final translation adheres to Chinese style without additional explanations or notes. The content to be translated is wrapped in the following tags:\n\n{text}. \n\nPlease write and output only the final polished translation here: "
}
================================================
FILE: pyproject.toml
================================================
[project]
name = "bbook-maker"
description = "The bilingual_book_maker is an AI translation tool that uses ChatGPT to assist users in creating multi-language versions of epub/txt files and books."
readme = "README.md"
license = {text = "MIT"}
dynamic = ["version"]
requires-python = ">=3.10"
authors = [
{ name = "yihong0618", email = "zouzou0208@gmail.com" },
]
classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
]
dependencies = [
"anthropic",
"backoff",
"bs4",
"ebooklib",
"google-generativeai",
"langdetect",
"litellm",
"openai>=1.1.1",
"PyDeepLX",
"requests",
"rich",
"tiktoken",
"tqdm",
"groq>=0.5.0",
"promptdown>=0.9.0",
"PyMuPDF",
]
[project.scripts]
bbook_maker = "book_maker.cli:main"
promptdown = "promptdown_cli:main"
[project.urls]
Homepage = "https://github.com/yihong0618/bilingual_book_maker"
[tool.pdm]
plugins = ["pdm-autoexport"]
[[tool.pdm.autoexport]]
filename = "requirements.txt"
without-hashes = true
[build-system]
requires = ["pdm-backend>=2.0.0"]
build-backend = "pdm.backend"
[tool.pdm.version]
source = "scm"
================================================
FILE: tests/test_epub_metadata.py
================================================
import pytest
from ebooklib import epub
from book_maker.loader.epub_loader import EPUBBookLoader
def test_epub_loader_handles_custom_metadata(tmp_path):
source_book = epub.EpubBook()
source_book.add_metadata("DC", "title", "Metadata Copy Test", {"id": "title-id"})
source_book.add_metadata("DC", "creator", "Tester", {"role": "aut"})
# Simulate a namespace that ebooklib does not recognise; the legacy approach
# copied this verbatim and ebooklib failed while writing the book back.
source_book.metadata["custom"] = [
("foo-tag", "bar-value", {"attr": "value"}),
]
legacy_book = epub.EpubBook()
legacy_book.metadata = source_book.metadata
with pytest.raises(AttributeError):
epub.write_epub(str(tmp_path / "legacy.epub"), legacy_book)
loader = EPUBBookLoader.__new__(EPUBBookLoader)
rebuilt_book = loader._make_new_book(source_book)
output_path = tmp_path / "rebuilt.epub"
epub.write_epub(str(output_path), rebuilt_book)
assert output_path.exists()
dc_namespace = epub.NAMESPACES["DC"]
titles = rebuilt_book.metadata[dc_namespace]["title"]
creators = rebuilt_book.metadata[dc_namespace]["creator"]
assert ("Metadata Copy Test", {"id": "title-id"}) in titles
assert ("Tester", {"role": "aut"}) in creators
assert "custom" not in rebuilt_book.metadata
================================================
FILE: tests/test_integration.py
================================================
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
@pytest.fixture()
def test_book_dir() -> str:
"""Return test book dir"""
# TODO: Can move this to conftest.py if there will be more unittests
return str(Path(__file__).parent.parent / "test_books")
def test_google_translate_epub(test_book_dir, tmpdir):
"""Test google translate epub"""
shutil.copyfile(
os.path.join(test_book_dir, "Liber_Esther.epub"),
os.path.join(tmpdir, "Liber_Esther.epub"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "Liber_Esther.epub"),
"--test",
"--test_num",
"20",
"--model",
"google",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "Liber_Esther_bilingual.epub"))
assert os.path.getsize(os.path.join(tmpdir, "Liber_Esther_bilingual.epub")) != 0
def test_deepl_free_translate_epub(test_book_dir, tmpdir):
"""Test deepl free translate epub"""
shutil.copyfile(
os.path.join(test_book_dir, "Liber_Esther.epub"),
os.path.join(tmpdir, "Liber_Esther.epub"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "Liber_Esther.epub"),
"--test",
"--test_num",
"20",
"--model",
"deeplfree",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "Liber_Esther_bilingual.epub"))
assert os.path.getsize(os.path.join(tmpdir, "Liber_Esther_bilingual.epub")) != 0
def test_google_translate_epub_cli():
pass
def test_google_translate_txt(test_book_dir, tmpdir):
"""Test google translate txt"""
shutil.copyfile(
os.path.join(test_book_dir, "the_little_prince.txt"),
os.path.join(tmpdir, "the_little_prince.txt"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "the_little_prince.txt"),
"--test",
"--test_num",
"20",
"--model",
"google",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "the_little_prince_bilingual.txt"))
assert os.path.getsize(os.path.join(tmpdir, "the_little_prince_bilingual.txt")) != 0
def test_google_translate_txt_batch_size(test_book_dir, tmpdir):
"""Test google translate txt with batch_size"""
shutil.copyfile(
os.path.join(test_book_dir, "the_little_prince.txt"),
os.path.join(tmpdir, "the_little_prince.txt"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "the_little_prince.txt"),
"--test",
"--batch_size",
"30",
"--test_num",
"20",
"--model",
"google",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "the_little_prince_bilingual.txt"))
assert os.path.getsize(os.path.join(tmpdir, "the_little_prince_bilingual.txt")) != 0
@pytest.mark.skipif(
not os.environ.get("BBM_CAIYUN_API_KEY"),
reason="No BBM_CAIYUN_API_KEY in environment variable.",
)
def test_caiyun_translate_txt(test_book_dir, tmpdir):
"""Test caiyun translate txt"""
shutil.copyfile(
os.path.join(test_book_dir, "the_little_prince.txt"),
os.path.join(tmpdir, "the_little_prince.txt"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "the_little_prince.txt"),
"--test",
"--batch_size",
"10",
"--test_num",
"100",
"--model",
"caiyun",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "the_little_prince_bilingual.txt"))
assert os.path.getsize(os.path.join(tmpdir, "the_little_prince_bilingual.txt")) != 0
@pytest.mark.skipif(
not os.environ.get("BBM_DEEPL_API_KEY"),
reason="No BBM_DEEPL_API_KEY in environment variable.",
)
def test_deepl_translate_txt(test_book_dir, tmpdir):
shutil.copyfile(
os.path.join(test_book_dir, "the_little_prince.txt"),
os.path.join(tmpdir, "the_little_prince.txt"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "the_little_prince.txt"),
"--test",
"--batch_size",
"30",
"--test_num",
"20",
"--model",
"deepl",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "the_little_prince_bilingual.txt"))
assert os.path.getsize(os.path.join(tmpdir, "the_little_prince_bilingual.txt")) != 0
@pytest.mark.skipif(
not os.environ.get("BBM_DEEPL_API_KEY"),
reason="No BBM_DEEPL_API_KEY in environment variable.",
)
def test_deepl_translate_srt(test_book_dir, tmpdir):
shutil.copyfile(
os.path.join(test_book_dir, "Lex_Fridman_episode_322.srt"),
os.path.join(tmpdir, "Lex_Fridman_episode_322.srt"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "Lex_Fridman_episode_322.srt"),
"--test",
"--batch_size",
"30",
"--test_num",
"2",
"--model",
"deepl",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "Lex_Fridman_episode_322_bilingual.srt"))
assert (
os.path.getsize(os.path.join(tmpdir, "Lex_Fridman_episode_322_bilingual.srt"))
!= 0
)
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="No OPENAI_API_KEY in environment variable.",
)
def test_openai_translate_epub_zh_hans(test_book_dir, tmpdir):
shutil.copyfile(
os.path.join(test_book_dir, "lemo.epub"),
os.path.join(tmpdir, "lemo.epub"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "lemo.epub"),
"--test",
"--test_num",
"5",
"--language",
"zh-hans",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "lemo_bilingual.epub"))
assert os.path.getsize(os.path.join(tmpdir, "lemo_bilingual.epub")) != 0
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="No OPENAI_API_KEY in environment variable.",
)
def test_openai_translate_epub_ja_prompt_txt(test_book_dir, tmpdir):
shutil.copyfile(
os.path.join(test_book_dir, "animal_farm.epub"),
os.path.join(tmpdir, "animal_farm.epub"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "animal_farm.epub"),
"--test",
"--test_num",
"5",
"--language",
"ja",
"--model",
"gpt3",
"--prompt",
"prompt_template_sample.txt",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "animal_farm_bilingual.epub"))
assert os.path.getsize(os.path.join(tmpdir, "animal_farm_bilingual.epub")) != 0
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="No OPENAI_API_KEY in environment variable.",
)
def test_openai_translate_epub_ja_prompt_json(test_book_dir, tmpdir):
shutil.copyfile(
os.path.join(test_book_dir, "animal_farm.epub"),
os.path.join(tmpdir, "animal_farm.epub"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "animal_farm.epub"),
"--test",
"--test_num",
"5",
"--language",
"ja",
"--prompt",
"prompt_template_sample.json",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "animal_farm_bilingual.epub"))
assert os.path.getsize(os.path.join(tmpdir, "animal_farm_bilingual.epub")) != 0
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="No OPENAI_API_KEY in environment variable.",
)
def test_openai_translate_srt(test_book_dir, tmpdir):
shutil.copyfile(
os.path.join(test_book_dir, "Lex_Fridman_episode_322.srt"),
os.path.join(tmpdir, "Lex_Fridman_episode_322.srt"),
)
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
os.path.join(tmpdir, "Lex_Fridman_episode_322.srt"),
"--test",
"--test_num",
"20",
],
env=os.environ.copy(),
)
assert os.path.isfile(os.path.join(tmpdir, "Lex_Fridman_episode_322_bilingual.srt"))
assert (
os.path.getsize(os.path.join(tmpdir, "Lex_Fridman_episode_322_bilingual.srt"))
!= 0
)
================================================
FILE: tests/test_pdf_cli.py
================================================
import subprocess
import sys
from pathlib import Path
import pytest
fitz = pytest.importorskip("fitz")
def test_pdf_cli_creates_txt_and_optional_epub(tmp_path):
pdf_path = tmp_path / "cli_test.pdf"
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), "CLI test\nPDF content")
doc.save(str(pdf_path))
# run CLI
subprocess.run(
[
sys.executable,
"make_book.py",
"--book_name",
str(pdf_path),
"--test",
"--test_num",
"5",
"--model",
"google",
],
check=True,
)
txt_out = tmp_path / "cli_test_bilingual.txt"
assert txt_out.exists()
assert txt_out.stat().st_size > 0
# if ebooklib is installed, an epub should be created
try:
import ebooklib
except Exception:
ebooklib = None
if ebooklib is not None:
epub_out = tmp_path / "cli_test_bilingual.epub"
assert epub_out.exists()
assert epub_out.stat().st_size > 0
================================================
FILE: tests/test_pdf_loader.py
================================================
import os
from pathlib import Path
import pytest
fitz = pytest.importorskip("fitz")
from book_maker.loader.pdf_loader import PDFBookLoader
class DummyModel:
def __init__(
self,
key,
language,
api_base=None,
temperature=1.0,
source_lang="auto",
**kwargs,
):
pass
def translate(self, text):
return f"{text}"
def translate_list(self, texts):
return [f"{t}" for t in texts]
def test_pdf_loader_extracts_and_translates(tmp_path):
pdf_path = tmp_path / "test.pdf"
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), "Hello world\nThis is a PDF test")
doc.save(str(pdf_path))
loader = PDFBookLoader(
str(pdf_path),
DummyModel,
key="",
resume=False,
language="en",
is_test=True,
test_num=5,
)
assert len(loader.origin_book) > 0
loader.make_bilingual_book()
out_file = tmp_path / "test_bilingual.txt"
assert out_file.exists()
assert out_file.stat().st_size > 0
# basic content check
content = out_file.read_text(encoding="utf-8")
assert "" in content
# if ebooklib is installed, an EPUB should also be produced
try:
import ebooklib
except Exception:
ebooklib = None
if ebooklib is not None:
epub_file = tmp_path / "test_bilingual.epub"
assert epub_file.exists()
assert epub_file.stat().st_size > 0
================================================
FILE: typos.toml
================================================
# See https://github.com/crate-ci/typos/blob/master/docs/reference.md to configure typos
[default.extend-words]
sur = "sur"
banch = "banch" # TODO: not sure if this is a typo or not
fo = "fo"
ba = "ba"
[files]
extend-exclude = ["LICENSE"]