# Technical Documentation: yihong0618/bilingual_book_maker

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/yihong0618/bilingual_book_maker)

### Overview (`README.md`)

<div align="left">

# Bilingual Book Maker

**[中文](./README-CN.md) | English**


The bilingual_book_maker is an AI translation tool that uses ChatGPT to assist users in creating multi-language versions of epub/txt/md/srt/pdf files and books. Use it only with material you have the right to translate — works for which you hold the necessary rights, suitably licensed or permitted works, public-domain books, or uses otherwise allowed by applicable law. Before using this tool, please review the project's **[disclaimer](./disclaimer.md)**.

Guides for every file type and option, and the measurements behind the defaults, are on the [wiki](https://github.com/yihong0618/bilingual_book_maker/wiki). If you use a coding agent (Codex, Claude Code), the repository's skill translates a book for you: [Translate with an agent](https://github.com/yihong0618/bilingual_book_maker/wiki/Translate-with-an-agent).

[](https://github.com/yihong0618/bilingual_book_maker/stargazers)
[](https://github.com/yihong0618/bilingual_book_maker/actions/workflows/make_test_ebook.yaml)
[](https://pypi.org/project/bbook-maker/)
[](https://www.python.org/downloads/)
[](./LICENSE)
[](https://github.com/psf/black)
[](https://github.com/BerriAI/litellm)

</div>




## Supported endpoints

OpenAI and Anthropic format endpoints are supported.
Usually it comes with three fields, two if you are using the official endpoints, such as `gpt-6-luna` (the default)
or `claude-sonnet-4-6`. 
Specify `openai`, or `anthropic` at `--api_format` for API request formats.
This argument also supports selecting some translation services (`google`, `caiyun`, `deepl`, `deeplfree`,
`tencent`, `customapi` — not an OpenAI format) or `codex`
if you want to use your Codex quota instead. 

`--provider` is an alternative way to pass credentials, through a JSON config file
`bbm_providers.json`. 

Epub tags classification is auto enabled on JSON-schema endpoints, and on any endpoint that can hold a conversation — the codex route and plain reseller proxies included — where the model is asked for exact `skip`/`translate` verdicts instead. Only routes with no conversation at all (the MT engines) fall back to translating `p` tags only, so some poetry or verse may be omitted there. See [Plan mode](#plan-mode) for details.

Older flags (`--model gpt4o`,
`--model gemini`, `--openai_key`, …) still work: see
[Models and languages](https://github.com/yihong0618/bilingual_book_maker/wiki/Model-and-language-notes).

## Preparation

1. ChatGPT or OpenAI token [^token]
2. epub/txt/md/pdf books
3. Environment with internet access or proxy
4. Python 3.10+

## Quick Start

A sample book, `test_books/animal_farm.epub`, is provided for testing purposes.
`--test` translates only its first few paragraphs.

```shell
pip install -r requirements.txt      # or: pip install -U bbook_maker
```

Then:

```shell
cp bbm_providers.example.json bbm_providers.json
# edit base_url, default_models and env_key in ./bbm_providers.json
python3 make_book.py --book_name test_books/animal_farm.epub --provider openai --test --use_context session
```

You can also pass the key on the command line:

```shell
python3 make_book.py --book_name test_books/animal_farm.epub \
  --key sk-... --model gpt-6-luna --api_base https://api.openai.com/v1 --test --use_context session
```

To spend a [Codex](https://developers.openai.com/codex/cli) subscription:

```shell
python3 make_book.py --book_name test_books/animal_farm.epub --model gpt-6-luna --api_format codex --test
```

Or hand it to a coding agent

```shell
git clone https://github.com/yihong0618/bilingual_book_maker.git
cd bilingual_book_maker
codex "Hi, please use bbm-plan to translate this book: test_books/animal_farm.epub into a bilingual Chinese-English edition, thanks."
```

## Endpoint flags

- `--api_format` names the API the endpoint speaks: `openai`, `anthropic`,
  `gemini`, `qwen`, `groq`, `xai`, `litellm`, `codex`, or one of the
  translation services (`google`, `caiyun`, `deepl`, `deeplfree`,
  `tencent`, `customapi`). A format that belongs to one vendor already
  knows that vendor's address, so the format and a `--key` are a whole
  command.
- **Any other OpenAI-compatible API**: `--api_base` (ending in `/v1`),
  `--key` the API key, and the model id in `--model`. Omit `--api_base` for
  OpenAI's own API, and `--model` for `gpt-6-luna`.
- Or translate through `--provider`: `bbm_providers.example.json` has an
  entry for each vendor below (Gemini, Qwen, xAI, Groq, OrcaRouter, Ollama,
  LiteLLM, SiliconFlow, OpenRouter). Copy it to
  `bbm_providers.json`, set the key in it, and `--provider gemini` uses the
  Gemini API from it.
- `--use_context session` translates in session mode; the history compacts
  at 8k by default (`--context-compact-at` overrides). It keeps one cached
  history for consistency, so recurring names stay stable across the book,
  and can also learn a glossary from its own handoff reports
  (`--glossary-auto on`, off by default) — the recommended mode on
  OpenAI-compatible endpoints, and what the examples below use.
- The old preset names and key flags still work, see
  [Migrating from the old flags](https://github.com/yihong0618/bilingual_book_maker/wiki/Migrating-from-old-flags).

## Supported translation services
* 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 --api_format deepl --key ${deepl_key}
  ```

* DeepL free

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format deeplfree
  ```

* [Claude](https://console.anthropic.com/docs)

  A `claude-*` model id selects the anthropic format on its own.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --model claude-sonnet-4-6 --key ${claude_key}
  ```

* Google Translate

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format google
  ```

* Caiyun Translate

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format caiyun --key ${caiyun_key}
  ```

* Gemini

  Google [Gemini](https://aistudio.google.com/app/apikey), over the Gemini
  API itself. Name any Gemini model id; without `--model` it is
  `gemini-flash-latest`. `--interval` sets the pause between requests, which
  is how the free tier's rate limit is stayed under.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format gemini --key ${gemini_key} --model gemini-flash-latest
  ```

* Qwen

  [Qwen-MT](https://www.aliyun.com/product/dashscope) on DashScope, a
  translation model: the request states a source and a target language.
  `qwen-mt-turbo` (the default) and `qwen-mt-plus` are supported, and
  `--source_lang` states the source language when auto-detection is not
  wanted.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format qwen --key ${qwen_key} --model qwen-mt-turbo --language "Simplified Chinese"
  ```

* [Tencent TranSmart](https://transmart.qq.com)

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format tencent
  ```

* [xAI](https://x.ai)

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format xai --key ${xai_key} --model grok-4.3 --use_context session
  ```

* [OrcaRouter](https://www.orcarouter.ai)

  The [OrcaRouter](https://www.orcarouter.ai) gateway, defaulting to its
  `orcarouter/auto` smart routing. The address comes with the route, so there
  is no `--api_base`; the key is `--key` or `BBM_ORCAROUTER_API_KEY`.
  `--provider orcarouter` reaches the same place.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --model orcarouter --key ${orcarouter_key} --use_context session
  ```

  To name one model instead: `--provider orcarouter --model <id>`.

* [Ollama](https://github.com/ollama/ollama)

  Translate with [Ollama](https://github.com/ollama/ollama) self-hosted models.
  If the ollama server is not local, point `--api_base http://x.x.x.x:port/v1` at it.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_base http://localhost:11434/v1 --model ${ollama_model_name} --use_context session
  ```

* [groq](https://console.groq.com/keys)

  `--model` is required: GroqCloud's catalogue turns over, so pick a current
  id from [Supported Models](https://console.groq.com/docs/models).

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format groq --key [your_key] --model llama-3.3-70b-versatile --use_context session
  ```

* [LiteLLM](https://docs.litellm.ai/docs/simple_proxy)

  A LiteLLM proxy, which fans out to whatever backends its own config names.
  `--model` is the name that config gives one of them. The default address is
  the proxy's own, on this machine; elsewhere it is `--api_base`.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format litellm --model ${name_in_your_litellm_config} --use_context session
  ```

* [Codex](https://developers.openai.com/codex/cli)

  Spend your ChatGPT/Codex plan. Install the
  [Codex CLI](https://developers.openai.com/codex/cli). The default model is `gpt-6-luna`; `--api_format codex --model <id>` names another. One session is reused for the whole book and compacted at `--context-compact-at`;
  it runs sandboxed, with shell, MCP servers and browsing off, but hooks may still fire.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format codex --language zh-hans
  ```

## Custom API Provider

  When the built-in models do not cover your needs, define a provider in a JSON config file. Without a code change, any OpenAI-compatible or Anthropic-format API (SiliconFlow, a local proxy, ...) becomes usable.

  Create `bbm_providers.json` in the current directory (or `~/.bbm/providers.json`):

  ```json
  {
    "providers": {
      "siliconflow": {
        "api_style": "openai",
        "base_url": "https://api.siliconflow.cn/v1",
        "default_models": ["Qwen/Qwen2.5-72B-Instruct"],
        "env_key": "BBM_SILICONFLOW_API_KEY"
      },
      "openai": {
        "api_style": "openai",
        "base_url": "https://api.openai.com/v1",
        "default_models": ["gpt-6-luna"],
        "env_key": "OPENAI_API_KEY",
        "prices": {
          "gpt-6-luna": {"input": 0.10, "output": 0.50, "cached_input": 0.01}
        }
      }
    }
  }
  ```

  Config fields:

  | Field | Required | Description |
  |-------|----------|-------------|
  | `api_style` | Yes | API request format: `openai`, `anthropic`, `gemini`, `qwen`, `groq`, `xai` or `litellm` |
  | `base_url` | No | The API address. Omitted means the api_style's default address |
  | `default_models` | No | Default model list. Required if `--model` is not provided |
  | `env_key` | No | Environment variable name for API key. Required if `--key` is not provided |
  | `prices` | No | Prices per million tokens, per model: `{"<model id>": {"input": …, "output": …, "cached_input": …}}`. When every model in the run has a price, the progress bar shows money spent (`spent=$0.012`) instead of token counts, and the closing line shows both. Without `cached_input`, cache reads are charged at the input price. A model without a price puts the bar back on tokens, and the closing line names it |
  | `currency` | No | Currency code for the prices, default `USD`. `USD`, `EUR`, `GBP`, `CNY` and `JPY` print with their symbol; any other code prints after the amount, as in `0.500 CHF` |

  The spent amount and the token counts are estimates, accumulated from the usage each request reports — close enough to steer by, but the vendor's bill is the number that counts.

  Priority: project-level `./bbm_providers.json` overrides global `~/.bbm/providers.json`.

  `--model` names a model at that provider; without it the first of `default_models` is used.

  ```shell
  python3 make_book.py --provider siliconflow --key sk-xxx --book_name test_books/animal_farm.epub --use_context session

  export BBM_SILICONFLOW_API_KEY=sk-xxx
  python3 make_book.py --provider siliconflow --book_name test_books/animal_farm.epub --use_context session
  ```

## Usage

- Once the translation is complete, a bilingual book named `${book_name}_bilingual.epub` would be generated for EPUB inputs; for TXT, MD and SRT inputs a bilingual file named `${book_name}_bilingual.txt`, `${book_name}_bilingual.md` or `${book_name}_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

- `--model`:

  The model id, exactly as the endpoint spells it. On the OpenAI format the default is `gpt-6-luna`. The second column is the `--api_format` the id needs:

  | model | `--api_format` | notes |
  |-------|---------------|-------|
  | `gpt-6-luna` | `openai` | the default, at OpenAI's own address |
  | `claude-sonnet-4-6` | `anthropic` | Anthropic's own address |
  | `gpt-4o-mini` | `openai` | OpenAI |
  | `gemini-flash-latest` | `gemini` | the default there, at Google's own address |
  | `qwen-mt-turbo` | `qwen` | the default there, on DashScope |
  | `llama-3.3-70b-versatile` | `groq` | Groq's own address |

  The old preset values still parse and are rewritten to a real model id with a note; [Migrating from the old flags](https://github.com/yihong0618/bilingual_book_maker/wiki/Migrating-from-old-flags) lists them. Anything else is an endpoint: `--api_base <url> --key <key> --model <id>`, or a `--provider` entry (see the Custom API Provider section).

- `--key`:

  API key for the endpoint. Without the flag the key is read from `$BBM_API_KEY`, then from the format's own variable. Same flag as `--api_key`.

- `--api_format`:

  The API the endpoint speaks. When omitted it is inferred: an `anthropic.com` host, or a model id containing `claude` with no `--api_base`, means `anthropic`; anything else means `openai`. Pass it when the guess is wrong, to reach a vendor without typing its address, or to pick an engine.

  | format | key | notes |
  |--------|-----|-------|
  | `openai` (default) | required: `--key`, else `$BBM_API_KEY`, `$OPENAI_API_KEY`; not for a local address such as Ollama | any OpenAI-compatible endpoint: OpenAI itself, OpenRouter, Ollama and the rest, the address in `--api_base` |
  | `anthropic` | required: `--key`, else `$BBM_API_KEY`, `$ANTHROPIC_API_KEY` | Anthropic itself, and gateways that speak the Messages API |
  | `gemini` | required: `--key`, else `$BBM_API_KEY`, `$BBM_GOOGLE_GEMINI_KEY`, `$GEMINI_API_KEY` | the Gemini API, default `gemini-flash-latest`; paced by `--interval` |
  | `qwen` | required: `--key`, else `$BBM_API_KEY`, `$BBM_QWEN_API_KEY`, `$DASHSCOPE_API_KEY` | Qwen-MT on DashScope, default `qwen-mt-turbo`; reads `--source_lang` |
  | `groq` | required: `--key`, else `$BBM_API_KEY`, `$BBM_GROQ_API_KEY`, `$GROQ_API_KEY` | GroqCloud; `--model` required |
  | `xai` | required: `--key`, else `$BBM_API_KEY`, `$BBM_XAI_API_KEY`, `$XAI_API_KEY` | xAI; `--model` required |
  | `litellm` | none for a proxy on this machine, else `--key` or `$LITELLM_MASTER_KEY` | a LiteLLM proxy, `http://localhost:4000` unless `--api_base` says otherwise; `--model` required |
  | `codex` | none: `codex login` (Codex CLI) | the local `codex app-server` sidecar on a ChatGPT/Codex plan, default `gpt-6-luna` |
  | `orcarouter` | required: `--key` or `$BBM_ORCAROUTER_API_KEY` | OrcaRouter |
  | `google` | none | Google Translate, free |
  | `caiyun` | required: `--key` or `$BBM_CAIYUN_API_KEY` | Caiyun |
  | `deepl` | required: `--key` or `$BBM_DEEPL_API_KEY` | DeepL (paid) |
  | `deeplfree` | none | DeepL free tier |
  | `tencent` | none | Tencent TranSmart, free |
  | `customapi` | none | a `{text, source_lang, target_lang}` format API |

- `--interval`:

  Seconds to wait between requests, e.g. `--interval 0.1` for 100ms. Only `--api_format gemini` paces itself with it; every other route ignores it. Default: `0.01`.

- `--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: a tag (`--language zh-hant`), a name (`--language "Traditional Chinese"`), or both at once — `--language "zh-hant:Traditional Chinese"`. The tag names the JSON structured-output field; the name is what the model is asked for. Default `zh-hans`. See also [available tags](https://github.com/yihong0618/bilingual_book_maker/wiki/Language-tags).

- `--source_lang`:

  Source language. Stated, it appends a prompt line ("Translate from English"), and on `--api_format qwen` (whose request names a language pair) and `--api_format customapi` it rides in the request itself. Default: auto-detect.

- `--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. Mutually
  exclusive with `--parallel-workers`.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --api_format google --resume
  ```

- `--translate-tags`:

  epub is made of html files. By default, we only translate contents in `<p>`.
  Use `--translate-tags` to specify tags need for translation. Use comma to separate multiple tags.
  For example: `--translate-tags h1,h2,h3,p,div`

- `--plan-classify` (epub only):

  **Plan mode**: classify epub tags with the translating model, or with codex / claude code.

  The value decides how is translation decision of each tag made:

  - `auto` (default): when the book is an epub, ask the LLM what to translate. Only when the route cannot hold a conversation, and when the plan fails, translate the `--translate-tags` selection instead. Rows decided over a plain session appear in `<book>_plan.json` with an `unnamed (…)` content type naming how the verdict was reached rather than what the content is.
  - `none`: no plan; only the `--translate-tags` selection — unselected, that defaults to `p`, most body text.
  - `all`: translate the whole partition, no classification.
  - `model`: the translating LLM judges, then translates. `--plan-classify-model X` picks the model that classifies.
  - `agent`: writes the classification plan for the book and prints instructions to paste into your coding tool for classification. 
  (or you could also do it by hand). Then run the translation with `--plan-classify agent` again.

  - `--plan-dry-run`: print the per-signature table, write `<book>_plan.json`, and exit. Honors `--only_filelist` / `--exclude_filelist`.
  - `<book>_plan.json`: the translation plan; delete it to classify again.
  - `--plan-min-coverage` (default 0.5, range 0–1): plan mode aborts if the plan covers less than this fraction of the text. `0` disables the guard and values above `0.9` usually abort after classification is already paid for — both warn.

  - `--max-batch-units`: the most units one grouped request may carry. Raise it together with `--accumulated_num` for fewer, larger (cheaper) requests; lower them once the run prints degradation warnings such as the misalignment-recovery hint. Content is also bounded by the token budget (`--accumulated_num`).

  ```shell
  # let the model judge which tags need translating
  python3 make_book.py --book_name my_book.epub --key ${key} --plan-classify model
  # or hand it to an agent: stops, prints instructions, then you give them to your AI
  python3 make_book.py --book_name my_book.epub --key ${key} --plan-classify agent
  ```

- `--exclude-translate-tags`:

  Use `--exclude-translate-tags` to exclude content within specified HTML tags from translation. This is useful for preserving code blocks, preformatted text, or other special content. Use comma to separate multiple tags.
  Default: `sup,code`.
  For example: `--exclude-translate-tags code,pre`

  **Tip**: Use `--exclude-translate-tags ""` to translate all content including code blocks (overrides the default exclusion).

- `--api_base`:

  If you want to change api_base like using Cloudflare Workers, use `--api_base <URL>` 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.

- `--prompt`:

  To tweak the prompt, use the `--prompt` parameter. The placeholders the `user` template may use are `{text}` (required), `{language}` and `{crlf}` (a newline, for the shapes — a JSON string, a table cell — that cannot carry one). Anything else in braces is refused before the run starts rather than raising mid-book; write `{{` and `}}` for a literal brace. 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`

  - 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.json`.

  - A third key, `style`, is a standing instruction about how to write — register, tone, vocabulary. It is said **once where a window starts**, not repeated on every request.

  - `--prompt` works on every LLM route, and on srt books too — there its sections sit on top of the subtitle loader's own prompt, section by section. Replacing the `user` template on an srt book means saying yourself that the block number and the timeline must come back unchanged; the run warns about it. The fixed translation-service routes (google, deepl, caiyun, tencent, qwen, customapi) send text and nothing else, so they carry no prompt at all and the run says so at start.
  
  - A `.md` file is read as the [PromptDown](https://github.com/btfranklin/promptdown) **block** form — the format is theirs, the reader is ours, so no extra package is installed: `--prompt prompt_md.prompt.md` (example at [./prompt_md.prompt.md](./prompt_md.prompt.md)). `## System Message`, an optional `## Style`, and a `## Conversation` whose `**User:**` turn is the template. The table form of a conversation is refused. Example:

      ```markdown
      # Translation Prompt

      ## System Message

      You are a professional translator who specializes in accurate translations.

      ## Conversation

      **User:**

      Please translate the following text into {language}:

      {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`.

  - An example JSON file can be found at [./prompt_template.json](./prompt_template.json).

- `--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.
  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 — on some local models that is close to the limit. You have to choose your own value, there is no way to know if the limit is reached before sending.

  In EPUB plan mode this is a per-request token budget: consecutive units of any length share one request up to `N` tokens. Pass `1` to turn grouping off — every unit is sent on its own.

- `--use_context`:

  Translate with context.

- `--context_paragraph_limit`:

  Use `--context_paragraph_limit` to set a limit on the number of context paragraphs when using the `--use_context` option. This applies to window mode only.

- `--use_context session`:

  Session mode keeps one append-only history and re-reads it at the cache
  price, so on endpoints that support caching the context can grow to about
  a chapter. When the history reaches the compact budget, the model writes
  a short handoff report (the run asks for ~300 tokens and truncates
  anything runaway), whose summary seeds the next window; `<book>_handoff.md`
  holds the latest snapshot, overwritten at each compaction.
  Watch the progress bar's `cached=`: if it is still zero after a dozen
  requests, the endpoint may not have a cache; Ctrl+C and switch to window
  mode.

  - `--context-compact-at`:

    Session mode only. The estimated-token budget the whole window — the
    inherited seed included — may reach before it is compacted into a
    handoff report. Default `8192`, minimum `1500`: a window shorter than
    that is mostly seed and seams, so below it use window mode instead.

  - `--no-context-compact`:

    Session mode only. Skip the handoff report. The window still rolls over at the budget, but the next one starts empty instead of inheriting a summary. Cheaper, at the cost of continuity across the seam.

- `--glossary` / `--terminology`:

  A file of `term → translation` lines — one per line, `#` starts a note or a
  comment, plain text.
  Read by the openai- and codex-shaped routes for EPUB, Markdown, and PDF books.

  A pinned term makes the translation say what you pinned, so pin only
  renderings you can stand behind.

  - `--glossary-auto on|off`:

    Keep the renderings the handoff reports establish, so recurring names
    stay unified across window seams. Session mode only, and off by
    default: it relies on the model reporting its own renderings
    accurately, which takes a capable model — most runs don't need it,
    since the handoff summary already carries the recurring names.

- `--parallel-workers`:

  Use `--parallel-workers` to process EPUB chapters or Markdown batches/sections in
  parallel. Values greater than `1` spin up multiple workers (recommended: `2-4`) and
  automatically fall back to sequential mode when there is only one unit of work. Other
  input loaders currently accept this shared CLI option but do not parallelize their work.

- `--temperature`:

  Sampling temperature for the openai and anthropic formats (the codex
  format has none). 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.
  For example: `--block_size 5`.

- `--single_translate`:

  Use `--single_translate` to output only the translated book without creating a bilingual version.

- `--no_disclosure`:

  An epub output adds "Translated by gpt-6-luna, 2026." below the book intro; this flag leaves it out. It also turns off the translation metadata (`--translation-metadata`: the model, the date and the glossary).

- `--translation_style`:

  Apply custom CSS to translated EPUB text, for example
  `--translation_style "color: #808080; font-style: italic;"`.

- `--translation_color`:

  Shorthand for setting only the translated EPUB text color, for example
  `--translation_color "#1e90ff"`. If `--translation_style` is also present, the full style
  takes precedence.

- `--pdf_layout {none,top-bottom,side-by-side,all}`:

  Select additional bilingual PDF outputs for PDF inputs. The default `none` creates no
  extra PDF; `all` attempts both top-bottom and side-by-side layouts. The bilingual TXT and
  EPUB outputs are unaffected.

- `--to-epub`, `--pdf-ocr`, `--ocr-lang`, `--ocr-engine`, `--ocr-replace-layer`, `--pages`, `--device`, `--pdf-image-dpi` (PDF only):

  Turn a PDF into a bilingual EPUB with a table of contents. See [PDF to bilingual EPUB](#pdf-to-bilingual-epub).

- `--sentence_mode`:

  Translate EPUB text sentence by sentence instead of translating each paragraph as one
  unit. It is incompatible with EPUB plan mode.

- `--batch` / `--batch-use`:

  Two-stage translation through the ChatGPT Batch API. Currently **refused on EPUB
  inputs**: the queue path is unreachable there, so such a run would translate live at
  full price and then submit an empty batch job instead of writing the book. Also refused
  on routes that do not implement the Batch API.

- `--quiet`:

  Suppress EPUB progress bars and per-paragraph source/translation echoes while retaining
  reports and errors. Recommended for log files and non-interactive agent runs.

- `--retranslate "$translated_filepath" "file_name_in_epub" "start_str" "end_str"`:

  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'
  ```

  To retranslate only the tag containing `start_str`, pass an empty fourth argument:

  ```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' ''
  ```

- `--no-thinking`:

  Ask a reasoning model not to think before answering; on a paragraph of
  prose it buys nothing and costs tokens and time.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --key ${openai_key} --no-thinking
  ```

- `--extra_body`:

  Pass additional JSON parameters on the routes built on the OpenAI request
  path — `openai` and the OpenAI-format custom providers — and on the
  `anthropic` route. Provide a JSON object with the desired parameters.
  Example:

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --key ${openai_key} --extra_body '{"chat_template_kwargs": {"enable_thinking": false}}'
  ```

- `--extra_headers`:

  Extra HTTP headers sent with every request, on the same routes. Values
  must be strings.

  ```shell
  python3 make_book.py --book_name test_books/animal_farm.epub --key ${openrouter_key} --api_base https://openrouter.ai/api/v1 --model anthropic/claude-haiku-4.5 --extra_headers '{"HTTP-Referer": "https://example.com", "X-Title": "bilingual_book_maker"}'
  ```

  Common forms, for reference:

  ```shell
  # openai route — disable a local/vLLM chat template's thinking block
  --extra_body '{"chat_template_kwargs": {"enable_thinking": false}}'
  # openai route (chat completions) — reasoning effort and a token ceiling,
  # neither of which has its own flag (both model-dependent)
  --extra_body '{"reasoning_effort": "low", "max_completion_tokens": 2000}'
  # anthropic route — keep extended thinking off; for translation it mostly
  # buys deviation from the source, not quality
  --extra_body '{"thinking": {"type": "disabled"}}'

  # OpenRouter attribution (shown on its dashboard)
  --extra_headers '{"HTTP-Referer": "https://example.com", "X-Title": "bilingual_book_maker"}'
  # a gateway's own auth or routing header (the value stays out of the logs)
  --extra_headers '{"X-API-Key": "sk-gateway-..."}'
  ```

- `--provider`:

  Use a custom provider defined in `bbm_providers.json`; `--model` picks a model at it. See the "Custom API Provider" section above.

- `--api_key`:

  Same as `--key`.

### Examples

**Note if use `pip install bbook_maker` all commands can change to `bbook_maker args`**

```
/* Detailed source-code truncated for AI context efficiency. */
```

More understandable example

```shell
python3 make_book.py --book_name 'animal_farm.epub' --key sk-XXXXX --api_base 'https://xxxxx/v1' --use_context session

# Or python3 is not in your PATH
python make_book.py --book_name 'animal_farm.epub' --key sk-XXXXX --api_base 'https://xxxxx/v1' --use_context session
```

Microsoft Azure Endpoints

```shell
python3 make_book.py --book_name 'animal_farm.epub' --key XXXXX --api_base 'https://example-endpoint.openai.azure.com/openai/v1' --model 'deployment-name' --use_context session

# Or python3 is not in your PATH
python make_book.py --book_name 'animal_farm.epub' --key XXXXX --api_base 'https://example-endpoint.openai.azure.com/openai/v1' --model 'deployment-name' --use_context session
```

## Features

### Plan mode

An EPUB is translated through a plan by default: the book is split into units, the model decides by tag signature what to translate, so verse, lists and table cells are not missed, and neighbouring units share one request. `--plan-classify` chooses who decides: `auto` (the default), `agent` (you or your coding agent, through the plan file), `all` or `none`.

```shell
# preview what would be translated and what skipped (no key needed)
python3 make_book.py --book_name my_book.epub --plan-dry-run
# decide the plan yourself or with a coding agent, then rerun the same command to translate
python3 make_book.py --book_name my_book.epub --key ${key} --plan-classify agent
```

`--plan-dry-run` honours `--only_filelist` / `--exclude_filelist`. `--classify-model` lets another model classify, `jev` for the Jev classifier; `--classify-base-url`, `--classify-key` and `--classify-min-confidence` go with it. More: [Plan mode](https://github.com/yihong0618/bilingual_book_maker/wiki/Plan-mode), [Recommended settings for EPUB](https://github.com/yihong0618/bilingual_book_maker/wiki/Recommended-settings-for-EPUB).

### Session mode

`--use_context session` keeps one conversation for the whole book, so names and style stay consistent. The history is re-read at the cache price and, once it reaches `--context-compact-at` (default 8192 tokens), compacted into a short handoff report; `--no-context-compact` starts the next window empty instead. Best on endpoints with prompt caching. More: [Session mode](https://github.com/yihong0618/bilingual_book_maker/wiki/Session-mode).

### PDF to **bilingual** EPUB

`--to-epub` turns a PDF into a reflowable bilingual EPUB with a table of contents: every paragraph followed by its translation, figures and display formulas kept as pictures. It needs the PDF extra, installed from a clone, and [Pandoc](https://pandoc.org/installing.html) 3.1.12 or newer; see [Installing the PDF extra](https://github.com/yihong0618/bilingual_book_maker/wiki/Installing-the-PDF-extra).

```shell
pip install ".[pdf]"
# two pages first; check the headings in paper_pages-1-2_book/source.md
python3 make_book.py --book_name paper.pdf --to-epub --pages 1-2 --key ${key} --use_context session
# then the whole file
python3 make_book.py --book_name paper.pdf --to-epub --key ${key} --use_context session
```

#### Scans (OCR)

`--pdf-ocr` reads pages that have no text layer. `--ocr-lang` names the languages to read (`iso:zh`, `iso:ja`, …), `--ocr-engine` picks the engine (`auto`, `rapidocr`, `ocrmac`, `easyocr`, `tesseract`), and `--ocr-replace-layer` reads every page again when the embedded text is wrong. More: [Which OCR engine](https://github.com/yihong0618/bilingual_book_maker/wiki/Which-OCR-engine).

```shell
python3 make_book.py --book_name scan.pdf --to-epub --pdf-ocr --ocr-lang iso:zh --key ${key}
```

#### Pages, figures and formulas

`--pages 12-30` (or `1,3,5-7`) translates only those pages, into `paper_pages-12-30_bilingual.epub`. Figures are drawn at 200 DPI; `--pdf-image-dpi 300` sharpens tiny labels. Display formulas are kept as pictures; `--no-formula-images` turns that off.

#### Layout and hardware

`--img-model MODEL` shows each page to a vision model that corrects what the layout detector got wrong, such as headings, captions and code; `--img-base-url` and `--img-key` point it at another endpoint. `--device` picks where the extraction models run (`auto`, `cpu`, `cuda`, `mps`, `xpu`).

More: [PDF to bilingual EPUB](https://github.com/yihong0618/bilingual_book_maker/wiki/PDF-to-bilingual-EPUB), [Recommended settings for PDF](https://github.com/yihong0618/bilingual_book_maker/wiki/Recommended-settings-for-PDF).

## Docker

You can use [Docker](https://www.docker.com/) if you don't want to deal with setting up the environment. Prebuilt images are published to GitHub Container Registry on every merge to `main` (as `latest`) and on every release tag:

```shell
docker pull ghcr.io/yihong0618/bilingual_book_maker:latest
```

Mount the folder containing your book at `/book` and pass the usual flags — the container accepts every `make_book.py` option, and the translated book is written back into the same folder:

```shell
# Linux / macOS
export folder_path=/path/to/your/books
export book_name=animal_farm.epub
export openai_key=sk-XXX
export language=zh-hans   # see the language list in book_maker/utils.py

docker run --rm -v "${folder_path}":/book ghcr.io/yihong0618/bilingual_book_maker:latest --book_name "/book/${book_name}" --key "${openai_key}" --language "${language}"
```

```powershell
# Windows PowerShell
$folder_path="C:\Users\user\mybook"
$book_name="animal_farm.epub"
$openai_key="sk-xxx"
$language="zh-hans"

docker run --rm -v ${folder_path}:/book ghcr.io/yihong0618/bilingual_book_maker:latest --book_name "/book/$book_name" --key $openai_key --language $language
```

For example, a quick test needing no key at all, over the free Google route:

```shell
docker run --rm -v /home/user/my_books:/book ghcr.io/yihong0618/bilingual_book_maker:latest --book_name /book/animal_farm.epub --api_format google --test --test_num 1 --language zh-hant
```

The container runs as root, so writing into the mounted folder always works; on Linux the files it writes there belong to root (`chown` them afterwards, or add `--user $(id -u)`). API keys can also be passed as environment variables (`-e OPENAI_API_KEY=sk-XXX`) instead of `--key`.

The `pdf` tag adds Pandoc and the PDF packages for `--to-epub`, and runs to several gigabytes:

```shell
docker run --rm -v "${folder_path}":/book -v bbm-models:/root/.cache ghcr.io/yihong0618/bilingual_book_maker:pdf --book_name /book/paper.pdf --to-epub --key "${openai_key}" --use_context session
```

For a GPU, find your machine:

- Linux with an NVIDIA card: the `pdf-cuda` tag; install the NVIDIA Container Toolkit and add `--gpus all`.
- Windows with an NVIDIA card: the same `pdf-cuda` tag, through Docker Desktop's WSL2 backend.
- Mac (Apple silicon): Docker cannot reach the GPU; install the tool natively instead.
- arm64 Linux with an NVIDIA card: the `pdf` tag, on the processor (`pdf-cuda` is amd64 only).

More: [Docker](https://github.com/yihong0618/bilingual_book_maker/wiki/Docker).

To build the image yourself instead of pulling:

```shell
docker build --tag bilingual_book_maker .
docker run --rm -v /path/to/your/books:/book bilingual_book_maker --book_name /book/animal_farm.epub --key sk-XXX --language zh-hans
```

## 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]: You can get a token from [OpenAI](https://platform.openai.com/account/api-keys) or [Anthropic](https://console.anthropic.com/account/api-keys).
[^black]: https://github.com/psf/black

---

### En Index (`docs/en/index.md`)

# bilingual book maker

bilingual book maker translates a book with a language model and writes a bilingual edition: every paragraph of the original, followed by its translation. It reads EPUB, TXT, Markdown, SRT and PDF files. It talks to OpenAI, Anthropic, Gemini, Qwen, any OpenAI-compatible endpoint (a gateway, a reseller, or a model on your own machine), a Codex subscription, and a few translation services.

```bash
pip install -U bbook_maker
export OPENAI_API_KEY=sk-...
bbook_maker --book_name my_book.epub --use_context session
```

That writes `my_book_bilingual.epub` next to `my_book.epub`. The default model is `gpt-6-luna`; the default target language is Simplified Chinese (`--language zh-hans`).

## Where to go next

- **Start here:** [Quick start](quickstart.md) (one EPUB, one TXT, one PDF) and [Installation](installation.md) (pip, a checkout, [Docker](docker.md)).
- **Have a coding agent?** [Translate with an agent](agent-skill.md): Codex or Claude Code with the repository's skill asks what you have, picks the flags and runs the tool for you.
- **EPUB:** [the command most people need](formats/epub.md), then [recommended settings](features/recommended-epub.md) for textbooks, small models and local servers; [plan mode](features/plan-mode.md) explains what happens.
- **PDF:** [PDF to bilingual EPUB](features/pdf-to-epub.md) (two pages first, then the whole file), [recommended settings](features/recommended-pdf.md) per document type and system, and [installing the PDF extra](installation-pdf.md).
- **Other formats:** [TXT](formats/txt.md), [SRT](formats/srt.md) and [Markdown](formats/md.md), one command each; [which page for which file](book_source.md).
- **Endpoints and models:** [Translating with an LLM](llm-args.md) (model, key, endpoint, retries, on-device models), [session mode](features/session-mode.md) (one conversation for the whole book, on every format that sends context), the [provider file](providers.md), [translation services](machine-args.md), [prompt files](prompt.md) and [environment variables](env_settings.md).
- **Evaluation:** [the measurements behind the parameters](evaluation/index.md), one parameter per page.
- **Reference:** [every command line option](cmd.md).

## Use it on material you may translate

Use it only with material you have the right to translate — works for which you hold the necessary rights, suitably licensed or permitted works, public-domain books, or uses otherwise allowed by applicable law. Before using this tool, please review the project's **[disclaimer](disclaimer.md)**.

---

### En Quickstart (`docs/en/quickstart.md`)

# Quick start

This page takes you from nothing to three bilingual books: an EPUB, a TXT and a PDF. It uses OpenAI's API and the default model, `gpt-6-luna`. Other endpoints are on [Translating with an LLM](llm-args.md).

## 1. Install

You need Python 3.10 or newer. A virtual environment is a good idea.

```bash
pip install -U bbook_maker
```

After the install, the command `bbook_maker` is on your PATH. From a checkout of the repository, `python make_book.py` is the same program; see [Installation](installation.md).

## 2. Give it a key

Put the key in the environment, so it stays off the command line.

```bash
export OPENAI_API_KEY=sk-...
```

You can pass `--key` instead. The lookup order is on [Translating with an LLM](llm-args.md#keys).

## 3. Try it on a few paragraphs first

`--test` translates only the first few paragraphs, so a mistake costs almost nothing. `--test_num` sets how many (default 10).

```bash
bbook_maker \
  --book_name test_books/animal_farm.epub \
  --test \
  --test_num 8
```

The sample books are in the repository's `test_books/` folder. Use your own file if you installed from pip.

## 4. An EPUB

```bash
bbook_maker \
  --book_name my_book.epub \
  --language zh-hans \
  --use_context session
```

- Output: `my_book_bilingual.epub`, beside the input.
- `--use_context session` keeps one conversation for the whole book, so names and terms stay consistent. See [Session mode](features/session-mode.md).
- The EPUB is translated through a plan by default: the tool finds every block of text (verse, tables and captions included) and asks the model which kinds are worth translating. See [Plan mode](features/plan-mode.md).

## 5. A TXT file

```bash
bbook_maker \
  --book_name test_books/the_little_prince.txt \
  --language zh-hans
```

- Output: `the_little_prince_bilingual.txt`, beside the input.
- Lines are sent in groups of `--batch_size` (default 10).

Markdown and SRT work the same way and give `<name>_bilingual.md` and `<name>_bilingual.srt`. See [Formats](formats/md.md).

## 6. A PDF

A PDF is best turned into a bilingual EPUB with a table of contents. That route needs the PDF extra and Pandoc 3.1.12 or newer; install them first with [PDF extra](installation-pdf.md). Then read two pages before you pay for the whole book:

```bash
python make_book.py \
  --book_name paper.pdf \
  --to-epub \
  --pages 1-2 \
  --test
```

Open `paper_pages-1-2_book/source.md` and read the headings. They become the table of contents. If they look right, run the whole file:

```bash
python make_book.py \
  --book_name paper.pdf \
  --to-epub \
  --use_context session
```

- Output: the working folder `paper_book/` (with `source.md`, the images, `book_bilingual.md` and a manifest) and a copy of the book, `paper_bilingual.epub`, beside the PDF.
- A scanned PDF needs `--pdf-ocr`. The run tells you when it does.
- Without `--to-epub`, a PDF takes the older route and gives `paper_bilingual.txt`. See [PDF](formats/pdf.md).

## If a run stops

Press Ctrl+C at any time. The run saves what it has; an EPUB run leaves `my_book_bilingual_temp.epub` beside the input. Run the same command again with `--resume` to continue:

```bash
bbook_maker \
  --book_name my_book.epub \
  --use_context session \
  --resume
```

A `--to-epub` run needs no `--resume`: rerun the same command and it picks up the extraction and the translation where they stopped.

---

### Zh Index (`docs/zh/index.md`)

# bilingual book maker

bilingual book maker 用语言模型翻译一本书，生成双语版：原文的每一段后面紧跟它的译文。它能读取 EPUB、TXT、Markdown、SRT 和 PDF 文件，可以对接 OpenAI、Anthropic、Gemini、Qwen、任何 OpenAI 兼容端点（网关、转售商，或跑在你自己机器上的模型）、Codex 订阅，以及几家翻译服务。

```bash
pip install -U bbook_maker
export OPENAI_API_KEY=sk-...
bbook_maker --book_name my_book.epub --use_context session
```

这会在 `my_book.epub` 旁边生成 `my_book_bilingual.epub`。默认模型是 `gpt-6-luna`，默认目标语言是简体中文（`--language zh-hans`）。

## 接下来看哪里

- **从这里开始：**[快速开始](quickstart.md)（一本 EPUB、一个 TXT、一个 PDF）和[安装](installation.md)（pip、仓库源码、[Docker](docker.md)）。
- **有编程智能体？**[用智能体翻译](agent-skill.md)：Codex 或 Claude Code 加载本仓库的技能后，会问清你手头有什么，替你选好参数并运行工具。
- **EPUB：**先看[大多数人需要的那条命令](formats/epub.md)，再看面向教材、小模型和本地服务器的[推荐设置](features/recommended-epub.md)；[计划模式](features/plan-mode.md)讲解其中发生了什么。
- **PDF：**[PDF 转双语 EPUB](features/pdf-to-epub.md)（先翻两页，再翻整个文件）、按文档类型和系统给出的[推荐设置](features/recommended-pdf.md)，以及[安装 PDF 扩展](installation-pdf.md)。
- **其他格式：**[TXT](formats/txt.md)、[SRT](formats/srt.md) 和 [Markdown](formats/md.md)，各只需一条命令；另见[各类文件对应的页面](book_source.md)。
- **端点与模型：**[用大模型翻译](llm-args.md)（模型、key、端点、重试、本地模型）、[会话模式](features/session-mode.md)（整本书一段对话，适用于每种会传上下文的格式）、[提供方文件](providers.md)、[翻译服务](machine-args.md)、[提示词文件](prompt.md)和[环境变量](env_settings.md)。
- **评测：**[参数背后的测量](evaluation/index.md)，每个参数一页。
- **参考：**[全部命令行参数](cmd.md)。

## 只用于你有权翻译的内容

请仅将本工具用于您有权翻译的内容——您持有必要权利的作品、许可或授权允许您翻译的作品、公有领域图书，或适用法律另行允许的使用方式。使用本工具之前，请阅读项目的 **[免责声明](disclaimer.md)**。

---

### Zh Quickstart (`docs/zh/quickstart.md`)

# 快速开始

本页带你从零开始做出三本双语书：一个 EPUB、一个 TXT 和一个 PDF。这里用的是 OpenAI 的 API 和默认模型 `gpt-6-luna`。其他端点见[用大模型翻译](llm-args.md)。

## 1. 安装

需要 Python 3.10 或更新版本，最好用一个虚拟环境。

```bash
pip install -U bbook_maker
```

安装后，`bbook_maker` 命令就在 PATH 上了。在仓库源码里，`python make_book.py` 是同一个程序，见[安装](installation.md)。

## 2. 提供 key

把 key 放进环境变量，这样它就不会出现在命令行上。

```bash
export OPENAI_API_KEY=sk-...
```

也可以改用 `--key` 传入。查找顺序见[用大模型翻译](llm-args.md#api-key)。

## 3. 先拿几段试试

`--test` 只翻译开头几段，出了错也几乎不花钱。`--test_num` 设定翻译多少段（默认 10）。

```bash
bbook_maker \
  --book_name test_books/animal_farm.epub \
  --test \
  --test_num 8
```

示例书放在仓库的 `test_books/` 文件夹里。如果你是用 pip 安装的，就换成你自己的文件。

## 4. 一本 EPUB

```bash
bbook_maker \
  --book_name my_book.epub \
  --language zh-hans \
  --use_context session
```

- 输出：`my_book_bilingual.epub`，就在输入文件旁边。
- `--use_context session` 整本书只保持一段对话，人名和术语因此前后一致。见[会话模式](features/session-mode.md)。
- EPUB 默认按计划翻译：工具找出每一块文字（包括诗行、表格和图题），再问模型哪几类值得翻译。见[计划模式](features/plan-mode.md)。

## 5. 一个 TXT 文件

```bash
bbook_maker \
  --book_name test_books/the_little_prince.txt \
  --language zh-hans
```

- 输出：`the_little_prince_bilingual.txt`，就在输入文件旁边。
- 每次请求发送 `--batch_size` 行（默认 10）。

Markdown 和 SRT 的用法相同，分别生成 `<name>_bilingual.md` 和 `<name>_bilingual.srt`。见[格式](formats/md.md)。

## 6. 一个 PDF

PDF 最好转成带目录的双语 EPUB。这条路线需要 PDF 扩展和 Pandoc 3.1.12 或更新版本，请先按 [PDF 扩展](installation-pdf.md)装好。然后在为整本书付费之前，先读两页：

```bash
python make_book.py \
  --book_name paper.pdf \
  --to-epub \
  --pages 1-2 \
  --test
```

打开 `paper_pages-1-2_book/source.md`，看一遍标题，它们会成为目录。如果标题没问题，就翻译整个文件：

```bash
python make_book.py \
  --book_name paper.pdf \
  --to-epub \
  --use_context session
```

- 输出：工作文件夹 `paper_book/`（里面有 `source.md`、图片、`book_bilingual.md` 和一份清单文件），以及 PDF 旁边的一份成书副本 `paper_bilingual.epub`。
- 扫描版 PDF 需要 `--pdf-ocr`，需要时运行会告诉你。
- 不加 `--to-epub` 时，PDF 走旧路线，生成 `paper_bilingual.txt`。见 [PDF](formats/pdf.md)。

## 运行中断时

随时可以按 Ctrl+C。运行会保存已完成的部分；EPUB 运行会在输入文件旁留下 `my_book_bilingual_temp.epub`。加上 `--resume` 再运行同一条命令即可继续：

```bash
bbook_maker \
  --book_name my_book.epub \
  --use_context session \
  --resume
```

`--to-epub` 运行不需要 `--resume`：重新运行同一条命令，它会从提取和翻译停下的地方接着做。

---

### En Evaluation Index (`docs/en/evaluation/index.md`)

# Measurements behind the parameters

Each page here measures one parameter: what was compared, on which sample, and the numbers that came out. The default is stated with the measurement it rests on, and each page ends with Limits: the sample size and what was not measured. Every number is copied from the measurement's own record.

## Translation

### [Units and tokens per request](grouping-batch-size.md)

`--max-batch-units`, `--accumulated_num`. Where grouped requests start to shift translations into the wrong slot (measured onset: 64 units), and the cost of the retry overhead at each size. Default 16 units and 1200 to 1600 tokens per request; 8 units and 800 tokens without a strict schema.

### [Session compact budget](session-compact-budget.md)

`--context-compact-at`. Session cost at 4096 and 8192 over 300 units, compactions counted. Default 8192, chosen for continuity on local models; 4096 used fewer tokens.

### [Jev as the plan classifier](plan-classifier-jev.md)

`--classify-model`, `--classify-min-confidence`. TypeSafe's Jev against gpt-5.6-luna on the 31 signatures of one book (27 of 31 agree), Jev's prompt tokens before and after the lean requests, and the confidence gate measured over 662 signatures in 45 EPUBs (0.95). Default: the translating model.

## PDF to EPUB

### [OCR engines on scanned pages](pdf-ocr-engines.md)

`--ocr-engine`. rapidocr, ocrmac, easyocr and tesseract on the same 20 scanned pages, character error rate and time per page, on the CPU. tesseract and ocrmac for English, rapidocr for simplified Chinese, ocrmac or rapidocr for traditional. Default `auto`.

### [Region roles with an image model](pdf-structure-llm-roles.md)

`--img-model`. 123 structure faults on 85 pages, and which of them a vision model relabelling regions fixes (40 of 66 wrong labels) and which it does not (running headers, shattered OCR pages). About 3,000 prompt tokens a page. Off unless a model is named.

---

### Zh Evaluation Index (`docs/zh/evaluation/index.md`)

# 参数背后的测量

这里每一页测量一个参数：比较了什么、用的是哪个样本、得出了哪些数字。默认值与它所依据的测量一起给出，每页最后是“局限”：样本规模，以及没有测量的内容。每个数字都照抄自该测量自己的记录。

## 翻译

### [每次请求的单元数与 token 数](grouping-batch-size.md)

`--max-batch-units`、`--accumulated_num`。合并请求从哪里开始把译文错放到别的位置（测得的起点：64 个单元），以及每种大小下重试开销的代价。默认每个请求 16 个单元、1200 到 1600 token；没有严格 schema 时为 8 个单元、800 token。

### [会话压缩预算](session-compact-budget.md)

`--context-compact-at`。300 个单元上 4096 与 8192 两档的会话成本，并统计了压缩次数。默认 8192，是为本地模型上的连续性而选；4096 用的 token 更少。

### [用 Jev 做计划分类器](plan-classifier-jev.md)

`--classify-model`、`--classify-min-confidence`。TypeSafe 的 Jev 与 gpt-5.6-luna 在一本书的 31 个签名上的对比（31 个中有 27 个一致），精简请求前后 Jev 的提示 token，以及在 45 本 EPUB 的 662 个签名上测得的置信度闸门（0.95）。默认：翻译模型。

## PDF 转 EPUB

### [扫描页上的 OCR 引擎](pdf-ocr-engines.md)

`--ocr-engine`。rapidocr、ocrmac、easyocr 和 tesseract 在同样 20 张扫描页上的字符错误率和每页耗时，均在 CPU 上。英文用 tesseract 和 ocrmac，简体中文用 rapidocr，繁体用 ocrmac 或 rapidocr。默认 `auto`。

### [用图像模型判定区域角色](pdf-structure-llm-roles.md)

`--img-model`。85 页上的 123 个结构错误，其中哪些能靠视觉模型重新标注区域修正（66 个错误标签中的 40 个），哪些不能（页眉、支离破碎的 OCR 页面）。每页约 3,000 个提示 token。除非指定模型，否则关闭。

---

### En Agent Skill (`docs/en/agent-skill.md`)

# Translate with an agent

You have a book and want it translated well. The shortest path is to hand it to a coding agent that carries this repository's `bbm-plan` skill. The agent asks what you have, picks the flags, shows you the command and the cost, runs the tool quietly, and hands back the bilingual book. You are never asked to learn a flag.

## What you need

- **A checkout of this repository.** The skill runs `make_book.py` from the repository root, so a `pip install bbook_maker` alone is not enough:

    ```bash
    git clone https://github.com/yihong0618/bilingual_book_maker.git
    cd bilingual_book_maker
    pip install -r requirements.txt   # pip install ".[pdf]" if you have PDFs
    ```

- **Credentials for the model you want**: an API key in `.env` or the environment, a model on your own machine, or a ChatGPT plan through the codex route. The agent asks which; see [Translating with an LLM](llm-args.md) for what each needs.
- **A coding agent that reads skills**: Codex, Claude Code, or any agent the `skills` CLI installs into.

## Codex

Codex reads `.agents/skills/` from the repository, so nothing is installed. Start `codex` in the checkout and describe the job, or name the skill:

```text
$bbm-plan translate ~/Books/my_book.epub to Japanese
```

## Claude Code and other agents

Install the skill once. The command finds the agents on your machine and asks which to install into:

```bash
npx skills add yihong0618/bilingual_book_maker --skill bbm-plan
```

Then start the agent in the checkout and describe the job. In Claude Code the skill also answers to its name:

```text
/bbm-plan translate ~/Books/my_book.epub to Japanese
```

## What the agent does

1. **Asks what you have.** The route (a key, a local model, a ChatGPT plan), the target language, a prompt file if you keep one, and anything you want changed from the defaults. Bilingual output is assumed: every original paragraph followed by its translation. Say "just the Chinese" if you want the translation alone.
2. **Probes the endpoint** with a request costing less than a cent, before anything is paid for.
3. **States the command**, one line per flag with the reason, the checkpoint you will see, and whose allowance it spends. Then it waits for your approval.
4. **Follows the flow for the file.**
    - An EPUB goes through [plan mode](features/plan-mode.md): the tool partitions the book, the agent itself decides what a running head, a page number or a footnote marker is against the real samples, and shows you the plan before the full run.
    - A PDF becomes a [bilingual EPUB](features/pdf-to-epub.md): two pages first, the agent reads what came out, then the whole file.
    - TXT, SRT and Markdown take one command each.
5. **Runs quietly**, logging to `run.log`, and resumes where it stopped after a crash or a new session. Everything it needs is on disk, nothing lives only in the conversation.
6. **Delivers** the `_bilingual` file next to your book.

## Reading the skill

The skill is the router `.agents/skills/bbm-plan/SKILL.md` plus one reference file per flow (route setup, EPUB plan mode, the PDF route, plain formats, prompt files, providers), loaded only when that step runs. Read it on GitHub: [`.agents/skills/bbm-plan`](https://github.com/yihong0618/bilingual_book_maker/tree/main/.agents/skills/bbm-plan). It points at the same pages as this wiki for anything it does not spell out.

---

### En Book Source (`docs/en/book_source.md`)

# Which page for which file

The file's extension picks how it is read. Find yours:

| if your file is… | start with | then read |
|---|---|---|
| an EPUB | `bbook_maker --book_name book.epub --use_context session` | [EPUB](formats/epub.md), [Recommended settings for EPUB](features/recommended-epub.md) |
| a PDF | `python make_book.py --book_name book.pdf --to-epub --pages 1-2 --test` | [PDF to bilingual EPUB](features/pdf-to-epub.md), [Recommended settings for PDF](features/recommended-pdf.md) |
| a plain-text file | `bbook_maker --book_name book.txt --batch_size 20` | [TXT](formats/txt.md) |
| subtitles | `bbook_maker --book_name talk.srt --accumulated_num 400` | [SRT](formats/srt.md) |
| a Markdown document | `bbook_maker --book_name doc.md --prompt prompt_md.json --use_context session` | [Markdown](formats/md.md) |

A PromptDown `.md` file is a prompt, not a book: it goes to `--prompt`, and a Markdown book goes to `--book_name`.

## An EPUB without a plan

If you want only chosen tags translated, turn the plan off and name the tags:

```bash
bbook_maker --book_name test_books/animal_farm.epub --plan-classify none --translate-tags div,p
```

If a book keeps text outside any tag, `--allow_navigable_strings` adds it too. A plan already covers both, so these are for books where you want exact control. A well-formed EPUB gives better results either way.

---

### En Cmd (`docs/en/cmd.md`)

# Command Line Options

`book_maker/cli.py` and `python3 make_book.py --help` are the runtime source of truth.
The inventory below is checked against every long option registered with `argparse`; the
sections after it provide additional notes for selected workflows.

## Complete option inventory

### Input, scope, and output

| Option | Purpose |
|---|---|
| `--book_name PATH` | Input EPUB, TXT, Markdown, SRT, or PDF path (required). |
| `--language LANGUAGE` | Target language: a tag (`zh-hant`), a name (`"Traditional Chinese"`), or `TAG:NAME` to pin both when the tables miss the language — tag → output stamps and field names, name → the prompt. Default `zh-hans`; list in `docs/en/languages.md`. |
| `--source_lang LANGUAGE` | Source language. Stated, it reaches every LLM route's prompt, and the request body on `qwen`/`customapi`; default `auto` (states nothing). |
| `--single_translate` | Output translation only instead of bilingual text. |
| `--no_disclosure` | Do not mark the epub as an AI translation: leaves out the one-line credit ("Translated by \<model\>, \<year\>.") below the book intro. Turns off `--translation-metadata` too. |
| `--translation-metadata` | Write a small `bbm_translation_metadata.json` into the book: the model, the date, and the sha256 of a `--glossary` file (whose text is embedded alongside; terms a run learned by itself are not). Nothing else — no command line, endpoint, languages or paths, and no package metas: what could have shaped the translation is recorded, who ran it is not. Format conversions keep manifest files, so the record survives them. Plan-mode and session runs write it by themselves; this flag adds it to a plain tag-mode run. `--no_disclosure` turns it off too. |
| `--translate-tags TAGS` | Comma-separated EPUB tags; default `p`, ignored in plan mode. |
| `--exclude-translate-tags TAGS` | EPUB ancestor tags to exclude; default `sup,code`; `""` clears it. |
| `--allow_navigable_strings` | Include otherwise untagged EPUB strings; redundant in plan mode. |
| `--only_filelist FILES` | Include only these comma-separated internal EPUB files. |
| `--exclude_filelist FILES` | Exclude these comma-separated internal EPUB files. |
| `--translation_style CSS` | CSS applied to translated EPUB entries. |
| `--translation_color COLOR` | Color-only shorthand; `--translation_style` takes precedence. |
| `--pdf_layout MODE` | Additional PDF output: `none`, `top-bottom`, `side-by-side`, or `all`. |
| `--to-epub` | PDF only: read the PDF with docling, translate the Markdown, write `<name>_bilingual.epub` beside it; figures stay pictures; the bundle stays in `<name>_book/` for editing and resume. Needs the `pdf` extra and Pandoc 3.1.12+ on PATH; no Java. See [installation-pdf.md](installation-pdf.md). |
| `--pdf-ocr` | PDF only, with `--to-epub`: read pages that carry no text layer, which are refused without it. Off by default; OCR costs several times the time and changes nothing on a born-digital PDF. Layout, headings and tables are detected either way. |
| `--ocr-replace-layer` | PDF only, with `--to-epub --pdf-ocr`: OCR every page and replace the PDF's own text layer instead of keeping it. Off by default; measured worse than a sound layer, for a layer that is wrong. A page read empty stays empty and is named. |
| `--no-formula-images` | PDF only, with `--to-epub`: leave display formulas as `` placeholders instead of cropping each one from the page as an image. The parser finds equations but never reads them, so the pictures are the only reason the mathematics reaches the book at all; they cost no model and no measurable time. Inline mathematics inside a paragraph is not a formula region and is not covered either way. |
| `--pdf-image-dpi N` | PDF, `--to-epub`: figure sharpness in DPI of the PDF's own page size; default 200, 300 for tiny labels; a rerun at another value redraws the figures only. |
| `--img-model MODEL` | PDF only, with `--to-epub`: a vision model that corrects the layout detector's region roles (text, heading, title, caption, footnote, code) from a page image before export. Off unless named here or as the provider entry's `img_model`; `none` turns that off. Never the run's own model by fallback. About 3k prompt tokens per page. |
| `--img-base-url URL` | Endpoint for `--img-model` when it is not the run's (OpenAI-compatible only). |
| `--img-key KEY` | Key for `--img-base-url`. Default: the run's key when the endpoint is the run's own; else the provider entry's `img_env_key` when the endpoint is the entry's own; else the key the endpoint's format reads from the environment. A key is never sent to an address it was not given for. |
| `--device DEVICE` | PDF only, with `--to-epub`: where the extraction models run — `auto` (default; detects CUDA or MPS, falls back to CPU), `cpu`, `cuda`, `mps`, `xpu`. CPU is fully supported and produces the same output, only slower. |
| `--ocr-lang LANGS` | PDF only, with `--to-epub --pdf-ocr`: the languages the OCR engine reads on pages with no text layer (every page with `--ocr-replace-layer`), comma separated, in the engine's own codes (rapidocr: `ch`, `en`, `latin`; easyocr: `ch_sim`, `ja`, `ko`; ocrmac: `zh-Hans`, `ja-JP`), or portable BCP-47 tags behind `iso:` (`iso:zh-Hans`, `iso:zh-Hant`, `iso:ja`, `iso:ko`), which docling 2.129 maps onto whichever engine runs. rapidocr reads one language per run and uses the first. The engine is the one docling selects on the install (with the PDF extra, ocrmac on a Mac, whose default reads English, Spanish, French and German, and rapidocr elsewhere, whose default reads Chinese and English), and the run prints the engine and languages it used. A scan in another script needs the flag; the run says so when it meets a scanned page without it. An unknown code is refused before any page is read. The codes per engine, and which engine to choose: [Which OCR engine](features/pdf-ocr-engines.md). |
| `--ocr-engine ENGINE` | PDF only, with `--to-epub --pdf-ocr`: the OCR engine for pages with no text layer (every page with `--ocr-replace-layer`). `auto` (default) takes the first installed of ocrmac, rapidocr, easyocr; the `pdf` extra installs `rapidocr` with onnxruntime, models included, and on macOS also `ocrmac` (Apple's Vision framework); neither downloads anything, so `auto` reads with ocrmac on a Mac and rapidocr elsewhere; `easyocr` downloads its models on first use (`pip install easyocr`); `tesseract` uses the tesseract program and its language data from PATH. An engine that is not installed, or `ocrmac` off macOS, is refused before any page is read. Language codes differ by engine (`--ocr-lang`); the run names the engine it used. Which to choose, measured: [Which OCR engine](features/pdf-ocr-engines.md). |
| `--pages PAGES` | PDF only, with `--to-epub`: the pages to read, numbered from 1 (`12-30`, `1,3,5-7`); the rest is left out. The selection goes into the bundle and book names (`<name>_pages-12-30_…`), so a chapter never overwrites the whole book. A selection that starts inside a section gets a `Page N` heading above its first prose, in `source.md`. |
| `--retranslate OUT FILE START END` | Retranslate an EPUB range in an existing output. EPUB only — refused elsewhere. |

### EPUB plan mode

| Option | Purpose |
|---|---|
| `--plan-dry-run` | Build and print the EPUB plan, write `<book>_plan.json` with every `action` still `null`, and exit. No credentials needed. |
| `--plan-classify {auto,none,all,model,agent}` | No plan, the whole partition, model triage, or coding-agent triage. Default `auto`: model triage on any epub endpoint that can answer — over structured output where a strict JSON schema is verified, over a plain conversation (exact `skip`/`translate` replies; anything else translates) elsewhere, codex included; tag mode only where no conversation exists. |
| `--classify-model MODEL` | Model for every classification step (old name `--plan-classify-model`), or a Jev-compatible classifier (TypeSafe's Jev by default; a gateway at its URL); implies model mode on an epub. Asked over a JSON schema where its endpoint verifies one, else over a plain conversation. Default: the provider entry's `classify_model`, else the run's model. |
| `--classify-base-url URL` | Endpoint for `--classify-model` when it is not the run's: an OpenAI-compatible endpoint, or a Jev-compatible classifier's URL (a path ending in `/systemone` is used as is). |
| `--classify-key KEY` | Key for `--classify-base-url`; same default rule as `--img-key`. `JEV_API_KEY`/`TYPESAFE_API_KEY` is read implicitly only at a typesafe.ai address, and a Cloudflare AI Gateway's `CF_AIG_TOKEN` only at gateway.ai.cloudflare.com; a provider entry may still bind a variable to its own address. |
| `--classify-min-confidence P` | Confidence gate for a Jev-compatible classifier, 0 to 1: a `skip` whose probability is below it becomes `translate`; `translate` is never gated. Default `0.95`, measured; below `0.5` the gate is off. `BBM_JEV_MIN_CONFIDENCE` sets it without the flag. |
| `--plan-min-coverage FRACTION` | Fail if selected planned text is below this fraction; default `0.5`, must be between 0 and 1 (`0` disables the guard, values above `0.9` usually abort — both warn). |
| `--poetry-group-size N` | Deprecated — general grouping and the session handoff give short lines their neighbours now, and the units cap is `--max-batch-units`. Still works (default `8`, minimum `1`) but warns. |

### Translation and execution

| Option | Purpose |
|---|---|
| `--test` | Translate only a preview sample. |
| `--test_num N` | Number of test units; default `10`. |
| `--resume` | Continue from the loader's saved checkpoint. An EPUB checkpoint records the run's language, prompt and model; a mismatch stops the resume (older checkpoints warn once and continue). Refused together with `--parallel-workers` and `--accumulated_num` above 1, where no checkpoint is ever written. |
| `--prompt VALUE_OR_FILE` | Prompt config: `user` (must contain `{text}`), `system`, and `style`. A `style` is a standing instruction said once where a window starts (the system message on the API routes, the thread instructions on codex), never repeated per request; a user-written style replaces the model's own style notes in handoff reports. A section with no native slot on a route (`system` on the `codex` format) is appended to the user message instead of dropped; a run with `--prompt` prints which sections it adopted and where. Sample: `prompt_template.json` (`style` shipped empty). |
| `--temperature FLOAT` | Sampling temperature, on the formats that take one; default `1.0`. The anthropic format always sends it. The openai format leaves it out, and the API default applies, when it equals that default and when the model rejects an explicit one (gpt-5.x, the o-series). The codex format has no such setting and ignores it. |
| `--use_context [window\|session]` | Send earlier paragraphs as context. Bare or `window`: re-send the last few source/translation pairs (the long-standing behaviour). `session`: one append-only history, re-read at the endpoint's prompt-cache rate. |
| `--context_paragraph_limit N` | Window mode only: context history limit. Parser default `0` means the translator default (3 paragraphs for ChatGPT), not zero history. |
| `--context-compact-at N` | Estimated-token budget for a rolling history. In session mode the history is compacted into a handoff report at this size; minimum `1500`. When unset, every session run — grouped or not, the `codex` format included — compacts at `8192`, printed at start. That default is chosen for continuity (the fewest window seams), not for price: a lower value is cheaper in session mode, because each request re-reads the carried history, so lower it if session cost matters more to you than seams, or set it to your model's input limit when that is smaller. Past about `16000` the cost climbs steeply. The measurements are on [Session compact budget](evaluation/session-compact-budget.md). Also bounds the plan classifier's conversation on endpoints that classify over a plain session (restart there, no handoff), with or without `--use_context`. An explicit value always wins. |
| `--no-context-compact` | Session mode only: skip the handoff report. The window still rolls over at the budget, but the next one starts empty. |
| `--glossary FILE` / `--terminology FILE` | A file of `term → translation` lines (one per line; `#` starts a note or a comment) this run must render that way. Two names for one flag. Only the terms that occur in a request are sent with it. A missing file stops the run at parse time. Read by the openai- and codex-shaped routes for EPUB, Markdown, and PDF books; other routes warn and ignore it. |
| `--glossary-auto on\|off` | Whether a session run also keeps the renderings its own handoff reports establish. Off unless you ask for it. It needs a session to learn from (`--use_context session`, or the `codex` format's one thread) and a model that answers with names rather than prose; `off` asks the compact turn for a summary only. Learned terms stay in this run and in `<book>_handoff.md`, and nowhere else. |
| `--accumulated_num N` | EPUB token/character accumulation and SRT subtitle-block character batching (capped at 512 for SRT). In EPUB plan mode it is a per-request token budget: consecutive units of any length share one request up to `N` tokens (at most `--max-batch-units` units per request; half that when the endpoint verifies JSON mode but not a strict schema). Untyped, every plan run derives a default from the run's own prompt overhead — `1200` with the stock prompts, up to `1600` under a long custom `--prompt` — halved per request on an endpoint without a strict-schema verdict, but never below the `800` floor (so the per-request budget there is `800` whatever the prompt costs); session runs (`codex` included) keep the un-halved value. These are chosen safety margins, below the range any measurement found fault-free; see [Units and tokens per request](evaluation/grouping-batch-size.md). The run narrates the number and the route class. Pass `1` to turn grouping off. Minimum `1`. |
| `--max-batch-units N` | EPUB plan mode only: the most units `--accumulated_num`'s token budget may put in one request. Default `16`, a quarter of the level where content faults first appeared (64 units per request), as a safety margin. An endpoint that verifies JSON mode but not a strict schema carries half this many (`8`), where reply miscounts actually live. Raise it if you trust your model and want fewer requests; lower it if the run keeps printing misalignment recoveries. |
| `--batch_size N` | Lines or paragraphs sent in one request by the TXT, Markdown and PDF (text route) loaders. Default `10`. |
| `--block_size N` | Merge paragraphs into delimiter-translated blocks. |
| `--sentence_mode` | Translate EPUB paragraphs sentence by sentence; incompatible with plan mode. |
| `--parallel-workers N` | Parallel EPUB chapters or Markdown batches/sections; default `1`. Refused with `--use_context session` (one history) and on the `codex` format (one thread). |
| `--batch` | Submit a ChatGPT Batch API job. Refused on EPUB (the queue path is unreachable there: the run would translate live at full price and submit an empty job instead of writing the book) and on routes without the Batch API. The TXT, SRT and Markdown loaders do not implement it either (a run translates live), so no format uses it at present. |
| `--batch-use` | Consume a previously submitted batch job. Refused on EPUB, like `--batch`. |
| `--no-thinking` | Ask the model not to reason before answering. On the OpenAI-shaped routes the request field is negotiated from the endpoint's own rejections and cached per endpoint and model; if every spelling is refused the run warns once and continues without one. On `anthropic` it is `thinking: {"type": "disabled"}`. Refused on `codex` (a subprocess has no request body); warned as inert on the routes that build their own request. A field set in `--extra_body` wins. |
| `--extra_body JSON` | Extra fields on every request body, for the routes that build one (`openai`, `groq`, `xai`, `litellm`, `--model orcarouter`, `anthropic`); the others ignore it and say so. Reaches the capability probe and the JSON rungs too, so the endpoint is graded on the request the run makes. Merged over the named parameters, so a field here beats the flag for it. |
| `--extra_headers JSON` | Extra HTTP headers on every request, same routes. Set on the client, so the capability probe, the model check and the model listing carry them. Values must be strings. |
| `--quiet` | Suppress EPUB progress bars and paragraph echoes, not reports/errors. |
| `--proxy URL` | Set HTTP/HTTPS proxy environment variables for the run. |

### Endpoint and credentials

A route is an endpoint, not a model name.

| Option | Purpose |
|---|---|
| `--model MODEL` | The model id, exactly as the endpoint names it (`gpt-5-mini`, `claude-sonnet-4-6`, `openai/gpt-5-mini`). Defaults to `gpt-6-luna` on the `openai` format; the `anthropic` format needs one. Old alias values are rewritten with a note. |
| `--api_base URL` | The endpoint. Defaults to the format's official host. A pasted `…/v1/chat/completions` or a trailing slash is trimmed. |
| `--key KEY` | API key; comma-separate several to rotate them. Prefer `BBM_API_KEY` or the format's own variable. |
| `--api_format FORMAT` | The API the endpoint speaks: `openai` (default), `anthropic`, `gemini`, `qwen`, `groq`, `xai`, `litellm`, `codex`, `google`, `caiyun`, `deepl`, `deeplfree`, `tencent`, `customapi`. Inferred from the `--api_base` host, else from a `claude`/`anthropic` model id — the vendor formats are never inferred, so they are named. |
| `--api_format gemini` \| `qwen` | Google's and Alibaba's own protocols, each with its own translator: Gemini's native constrained decoding and chat history, and Qwen-MT's language-pair request. Defaults `gemini-flash-latest` and `qwen-mt-turbo`. |
| `--api_format groq` \| `xai` \| `litellm` | The OpenAI shape at Groq, xAI and a LiteLLM proxy (`http://localhost:4000`). Each carries its address, so the format and a key are the whole route. `--model` is required: those catalogues turn over, so none is assumed. |
| `--model codex` | The Codex CLI sidecar on a ChatGPT plan, the same as `--api_format codex`. It runs `gpt-6-luna`; `--api_format codex --model <id>` names another (the sidecar also offers `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.5`, `gpt-5.2`). |
| `--model orcarouter` | The OrcaRouter gateway and its smart-routing model `orcarouter/auto`. Needs no `--api_base`; one you pass wins. The key comes from `BBM_ORCAROUTER_API_KEY`. Not a legacy alias: nothing is rewritten. |
| `--model_list IDS` | Several model ids to rotate across, comma-separated. A single model belongs in `--model`; naming a model in both flags is an error. Refused with `--use_context session`: rotation makes every request a full-price cache miss and mixes models in one conversation. |
| `--source_lang LANG` | Source language. Stated, it reaches every LLM route's prompt as evidence, and the request itself on `qwen`/`customapi`; default `auto`. |
| `--interval SECONDS` | Pause between requests, default `0.01`. Only the `gemini` route paces itself with it. |
| `--provider NAME` | A named endpoint from `bbm_providers.json` (this directory) or `~/.bbm/providers.json`; the project file wins on a shared name, and a name in neither falls back to the shipped `bbm_providers.example.json`, with a warning naming the address and key variable it used (its `FILL-ME` templates excluded). Its `base_url`, `api_style` (`openai`, `anthropic`, `gemini`, `qwen`, `groq`, `xai` or `litellm`), `default_models` and `env_key` stand in for `--api_base`, `--api_format`, `--model`/`--model_list` and the key; its `img_model`/`img_base_url`/`img_env_key` and `classify_model`/`classify_base_url`/`classify_env_key` stand in for the `--img-model`/`--img-base-url`/`--img-key` and `--classify-model`/`--classify-base-url`/`--classify-key` flags (see [Provider file and extra models](providers.md)). Flags you pass yourself win. |

A gateway that serves Claude models speaks the OpenAI shape, and a gateway
`--api_base` is taken to be that shape. The anthropic format is inferred only
on Anthropic's own host, or from a `claude` model id with no `--api_base`. A
gateway asked for the anthropic shape it does not serve answers 404, and the
run stops naming `--api_format openai` as the fix.

Key lookup order: `--key` (`--api_key` is the same flag), then `BBM_API_KEY`,
then the format's own: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
`BBM_GOOGLE_GEMINI_KEY`, `BBM_QWEN_API_KEY`, `BBM_GROQ_API_KEY`,
`BBM_XAI_API_KEY`, `BBM_CAIYUN_API_KEY`, `BBM_DEEPL_API_KEY` (each with the
vendor's own conventional variable after it). Endpoints on localhost need no
key.

The old `--model` preset names, the per-vendor `--*_key` flags,
`--ollama_model` and `--deployment_id` are no longer in the parser, but old
command lines still run: `book_maker/legacy_cli.py` rewrites
them into the flags above before the run starts and prints each rewrite. The
table is in [Migrating from the old flags](migration.md). The old
per-vendor key variables (`BBM_GROQ_API_KEY`, `BBM_GOOGLE_GEMINI_KEY`, …) are
still read for the route that used them.

Do not put secrets directly on a shared command line. Environment variables are safer for
agent and CI use. The CLI does **not** load `.env` files itself: export the variables first,
or source a local git-ignored file before running, for example
`set -a; source .env; set +a; bbook_maker ...`.

## Test translate
`--test` <br>

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 --key ${openai_key} --model gpt-5-mini  --test
```

```sh
bbook_maker --book_name test_books/animal_farm.epub --key ${openai_key} --model gpt-5-mini  --test --language zh-hans
```

`--test_num <TEST_NUM>`<br>

Use this option to set how many paragraph you want to translate for testing. Default is 10.

## Resume
`--resume` <br>

Use this option to manually resume the process after an interruption.

## Retranslate (epub only)
`--retranslate <translated_filepath> <file_name_in_epub> <start_str> <end_str>`<br>

If a file in an EPUB is not translated well, this re-translates part of it separately.
Argparse requires all four values. Use an empty `end_str` to retranslate only the starting
tag; an empty `file_name_in_epub` enables automatic filename lookup.

- 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 the `start_str` tag (empty fourth value):
        
        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 the `start_str` tag and auto-find the filename (empty second and fourth values):
        
        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, make sure the tag after `end_str` is translated content. When `end_str` is an empty string, the tag after `start_str` is used. There can be missing translations between the two strings, but a non-translated end boundary will cause problems.**




## Customize output style (epub only)
`--translation_style <TRANSLATION_STYLE>`<br>

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 <PROXY>` <br>

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 <API_BASE_URL>`<br>

If you want to change api_base like using Cloudflare Workers, use this option to support it.<br>

    bbook_maker --book_name 'animal_farm.epub' --key sk-XXXXX --model gpt-5-mini --api_base 'https://xxxxx/v1'
**Note: the api url should be '`https://xxxx/v1`'. Quotation marks are required.**

## Microsoft Azure Endpoints

Azure has no flag of its own. Point `--api_base` at the deployment's
OpenAI-compatible URL and name the deployment in `--model`:

    bbook_maker --book_name 'animal_farm.epub' --key XXXXX --api_base 'https://example-endpoint.openai.azure.com/openai/v1' --model 'deployment-name'

## Batch size (TXT, Markdown, PDF text route)
`--batch_size`<br>

Use this parameter to specify the number of lines for batch translation. Default is 10. Read by the TXT, Markdown and PDF (text route) loaders; EPUB uses `--accumulated_num`.
```sh
python3 make_book.py --book_name test_books/the_little_prince.txt --test --batch_size 20
```

## Accumulated Num
`--accumulated_num <ACCUMULATED_NUM>`<br>

Wait for how many tokens have been accumulated before starting the translation. gpt3.5 limits the total_token to 4090. 

In EPUB plan mode you rarely need it: the run derives a per-request budget (1200 with the stock prompts, 800 on an endpoint without a strict schema) and prints it. See the `--accumulated_num` row above and [Units and tokens per request](evaluation/grouping-batch-size.md).

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.

---

### En Disclaimer (`docs/en/disclaimer.md`)

# Disclaimer

bilingual_book_maker helps users create multilingual versions of books and other documents.

1. **Use only material you are entitled to translate.** This includes works for which you hold the necessary rights, works whose license or the rights holder's permission allows your intended use, works in the public domain under applicable law, and uses otherwise permitted by applicable law. Owning or buying a copy does not itself grant translation rights.

2. **Check translation and distribution rights separately.** Translation is generally an adaptation (a derivative work). Personal use is not automatically exempt from copyright restrictions. If you share a translation, ensure that your license, permission, or applicable law also allows distribution of everything included in the output, including any original text and images. The same license or permission may cover both translation and distribution. For Creative Commons works, check the specific license and version, including attribution, NonCommercial, ShareAlike, and NoDerivatives conditions where applicable. For example, NoDerivatives licenses do not authorize public sharing of translations. Public-domain status varies by jurisdiction; modern translations, editorial contributions, forewords, and illustrations may be protected separately from an older original.

3. **The MIT license does not license book content.** The project's MIT license covers its software and associated documentation; it grants no rights to input books or other third-party content and does not automatically apply to generated files. Use of the output remains subject to applicable law and any relevant licenses or permissions. The tool does not determine copyright ownership or verify that your intended use is permitted.

4. **Preservation is not rights verification.** For EPUB output, the tool carries over source attribution and rights metadata where it can. This does not establish that translation or distribution is permitted. Check the output and retain or supply attribution, copyright notices, license information, and indications of changes as required by the applicable license or permission.

5. **No warranty; limitation of liability.** The software is provided “as is,” without warranty, as set out in the project's MIT license. To the extent permitted by applicable law, the authors and copyright holders disclaim liability arising from use of the software, as set out in that license.

For questions or suggestions about the project, please open an issue.

# 免责声明

bilingual_book_maker 帮助用户制作图书及其他文档的多语言版本。

1. **请仅翻译您有权翻译的内容。** 这包括您持有必要权利的作品、许可协议或权利人授权允许您按预期方式使用的作品、依据适用法律已进入公有领域的作品，以及适用法律另行允许的使用情形。持有或购买一本书或一个副本，本身并不授予翻译权。

2. **分别核实翻译和分发的权利依据。** 翻译通常属于改编（演绎作品）。个人使用并不自动免受著作权限制。如果您分享译本，请确认许可协议、授权或适用法律也允许分发输出文件中包含的全部内容，包括原文和图片。同一许可协议或授权可以同时涵盖翻译与分发。使用知识共享（CC）许可的作品时，请核实具体许可及版本，包括其中适用的署名、非商业性使用、相同方式共享和禁止演绎等条件。例如，禁止演绎许可不授权公开分享译本。作品是否进入公有领域因司法辖区而异；现代译本、编辑新增内容、序言及插图可能独立于古旧原文受到保护。

3. **MIT 许可证不为图书内容授予许可。** 本项目的 MIT 许可证覆盖软件及随附文档；它不授予输入图书或其他第三方内容的任何权利，也不会自动适用于生成的文件。输出内容的使用仍须遵守适用法律及相关许可或授权。工具不会判定著作权归属，也不会核实您的预期使用方式是否获准。

4. **保留信息不等于核实权利。** 生成 EPUB 时，工具会尽量保留源图书的署名与权利元数据。这并不代表翻译或分发已获许可。请检查输出内容，并按适用许可或授权的要求，保留或补充署名、版权声明、许可信息及修改说明。

5. **不提供担保；责任限制。** 软件按本项目 MIT 许可证的规定“按原样”提供，不附带任何担保。在适用法律允许的范围内，作者及著作权人按该许可证的规定，不承担因使用软件而产生的责任。

如对项目有问题或建议，请通过 issue 联系我们。

---

### En Docker (`docs/en/docker.md`)

# Docker

Use Docker if you do not want to set up Python yourself. Images are published to the GitHub Container Registry on every merge to `main` and on every release tag. The container runs `python make_book.py`, so every flag on this site works unchanged.

## Images and tags

There are three images, built from one Dockerfile.

| tag | what is in it | platforms | use it for |
|---|---|---|---|
| `latest` (also `basic`) | the translator and its Python packages on `python:3.12-slim`; about 280 MB on amd64 | amd64, arm64 | EPUB, TXT, Markdown, SRT, and PDFs on the older text route |
| `pdf` | `latest` plus Pandoc 3.11 and the PDF packages, with PyTorch's CPU build; about 2.4 GB on amd64 | amd64, arm64 | `--to-epub` on the processor: macOS, arm64 Linux, and any machine without an NVIDIA card |
| `pdf-cuda` | the official PyTorch CUDA runtime image plus Pandoc 3.11, the translator and the PDF packages; its base alone is a 3.4 GB download | amd64 | `--to-epub` on an NVIDIA GPU, on Linux or on Windows |
| `<version>`, `<version>-pdf`, `<version>-pdf-cuda` | the same three images at a release | | pinning a release |
| `sha-<commit>`, `sha-<commit>-pdf`, `sha-<commit>-pdf-cuda` | the same three images at one commit | | pinning a build |

```bash
docker pull ghcr.io/yihong0618/bilingual_book_maker:latest
```

## Mounts

- **Your book folder at `/book`.** Pass the book as `/book/<file>`. The translated book is written back into the same folder.
- **A named volume at `/root/.cache`** (the `pdf` and `pdf-cuda` images). The docling models download there on the first `--to-epub` run, about 500 MB. Without the volume every run downloads them again.

The container runs as root, so writing into the mounted folder always works. On Linux the files it writes belong to root: `chown` them afterwards, or add `--user $(id -u)`.

Pass the key as an environment variable rather than on the command line: `-e OPENAI_API_KEY`.

## An EPUB, per system

=== "Linux / macOS"

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:latest \
      --book_name /book/my_book.epub \
      --language zh-hans \
      --use_context session
    ```

=== "Windows PowerShell"

    ```powershell
    docker run --rm `
      -v "${PWD}:/book" `
      -e OPENAI_API_KEY `
      ghcr.io/yihong0618/bilingual_book_maker:latest `
      --book_name /book/my_book.epub `
      --language zh-hans `
      --use_context session
    ```

A test that needs no key at all, over the free Google route:

```bash
docker run --rm \
  -v "$PWD":/book \
  ghcr.io/yihong0618/bilingual_book_maker:latest \
  --book_name /book/animal_farm.epub \
  --api_format google \
  --test \
  --test_num 1 \
  --language zh-hant
```

## A PDF, per system

The `latest` image has no Pandoc and no PDF packages, so it cannot run `--to-epub`. With an NVIDIA card use the `pdf-cuda` tag; everywhere else use `pdf`.

=== "Linux with NVIDIA"

    Install the NVIDIA driver and the NVIDIA Container Toolkit on the host. Nothing else: the image carries the CUDA runtime.

    ```bash
    docker run --rm --gpus all \
      -v "$PWD":/book \
      -v bbm-models:/root/.cache \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:pdf-cuda \
      --book_name /book/paper.pdf \
      --to-epub \
      --use_context session
    ```

=== "Windows with NVIDIA"

    Use Docker Desktop on the **WSL2 backend**. Install the WSL-capable NVIDIA driver on Windows itself, not inside WSL. Windows-containers mode cannot reach the GPU.

    ```powershell
    docker run --rm --gpus all `
      -v "${PWD}:/book" `
      -v bbm-models:/root/.cache `
      -e OPENAI_API_KEY `
      ghcr.io/yihong0618/bilingual_book_maker:pdf-cuda `
      --book_name /book/paper.pdf `
      --to-epub `
      --use_context session
    ```

=== "CPU only"

    The `pdf` image carries PyTorch's CPU build, for amd64 and arm64. `--device cpu` skips the accelerator detection. The output is the same as on a GPU; it is slower.

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -v bbm-models:/root/.cache \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:pdf \
      --book_name /book/paper.pdf \
      --to-epub \
      --device cpu \
      --use_context session
    ```

=== "macOS"

    The container is CPU-only whatever you pass: Docker runs a Linux VM that cannot see Metal. The `pdf` image runs natively on Apple silicon (arm64). The [native install](installation-pdf.md) is faster there (MPS).

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -v bbm-models:/root/.cache \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:pdf \
      --book_name /book/paper.pdf \
      --to-epub \
      --device cpu \
      --use_context session
    ```

## An NVIDIA card on arm64

`pdf-cuda` is built for amd64 only: the official PyTorch CUDA image it starts from is published for nothing else. On an arm64 Linux host with a card (GH200, Jetson), `pdf` runs on the processor.

## What is not in any image

The Codex route (`--api_format codex`). It drives a `codex` binary signed in on your machine; neither the binary nor the login is in the container. Use an API route in Docker.

## Build it yourself

A plain build gives the small image:

```bash
docker build --tag bilingual_book_maker .
```

The PDF images are the `pdf` and `pdf-cuda` stages:

```bash
docker build --target pdf --tag bilingual_book_maker:pdf .
docker build --platform linux/amd64 --target pdf-cuda --tag bilingual_book_maker:pdf-cuda .
```

---

### En Env Settings (`docs/en/env_settings.md`)

# Environment Settings

You can write credentials into the environment to skip `--key`.

## API key

`BBM_API_KEY` covers every format, so one variable is usually enough:

```
export BBM_API_KEY=${your_api_key}
```

If it is not set, each format also looks at the variable people already
export for that vendor:

| `--api_format` | Fallback variables |
|---|---|
| `openai` | `OPENAI_API_KEY`, `BBM_OPENAI_API_KEY` |
| `anthropic` | `ANTHROPIC_API_KEY`, `BBM_CLAUDE_API_KEY` |
| `gemini` | `BBM_GOOGLE_GEMINI_KEY`, `GEMINI_API_KEY` |
| `qwen` | `BBM_QWEN_API_KEY`, `DASHSCOPE_API_KEY` |
| `groq` | `BBM_GROQ_API_KEY`, `GROQ_API_KEY` |
| `xai` | `BBM_XAI_API_KEY`, `XAI_API_KEY` |
| `litellm` | `BBM_LITELLM_API_KEY`, `LITELLM_MASTER_KEY` |
| `caiyun` | `BBM_CAIYUN_API_KEY` |
| `deepl` | `BBM_DEEPL_API_KEY` |

`google`, `deeplfree`, `tencent` and `customapi` need no key, and neither
does an endpoint on localhost — a LiteLLM proxy there included, which is
where `--api_format litellm` points unless `--api_base` says otherwise.

The CLI does not read `.env` files. Export the variables first, or source a
git-ignored file before running: `set -a; source .env; set +a; bbook_maker ...`

## A provider's own variable

`--provider NAME` reads the endpoint from `bbm_providers.json` in the working
directory, else `~/.bbm/providers.json`. When that entry has an `env_key`,
the named variable is consulted for the key ahead of `BBM_API_KEY` and the
fallbacks above — it names the endpoint being called, so its own key is the
right one. It is consulted only while the run still calls that endpoint: an
`--api_base` elsewhere, or an `--api_format` override on an entry that names
no `base_url`, moves the request to another host, and the entry's key is not
sent there. The run says so; pass `--key` when you meant to reuse it. The file holds the address and the variable name, never a secret.
See [Endpoints, models and languages](./model_lang.md#named-endpoints-provider).
`--model orcarouter` reads `BBM_ORCAROUTER_API_KEY` the same way.

## Classifier variables

A [Jev-compatible classifier](providers.md#jev-and-jev-compatible-classifiers) reads its key only for its own host:

| variable | read for |
|---|---|
| `JEV_API_KEY`, `TYPESAFE_API_KEY` | TypeSafe's Jev at a typesafe.ai address |
| `CF_AIG_TOKEN` | an authenticated Cloudflare AI Gateway at gateway.ai.cloudflare.com |

Through a gateway, name the key with `--classify-key` or a provider entry's `classify_env_key`.

`BBM_JEV_MIN_CONFIDENCE`, a number from 0 to 1, sets Jev's gate for a run without `--classify-min-confidence` (the flag wins): a `skip` below it is translated. The default is 0.95. A value that is not a number from 0 to 1 stops the run.

## Old per-vendor variables

The four vendor variables above are also read when an old-style command
implies that route — `--model gemini` consults `BBM_GOOGLE_GEMINI_KEY` even
though the rewritten command names the format (see [Migrating from the old
flags](migration.md)). What they are never consulted for is a command whose
`--api_base` names some other endpoint: there the key must match the address,
and only `BBM_API_KEY` and the format's own variables apply.

## Prompt overrides

```
export BBM_CHATGPTAPI_USER_MSG_TEMPLATE=${your_prompt_template}
export BBM_CHATGPTAPI_SYS_MSG=${your_system_message}
```

---

### En Installation Pdf (`docs/en/installation-pdf.md`)

# Installing the PDF extra

If you want to turn a PDF into a bilingual EPUB (`--to-epub`), install the PDF extra from a clone of the repository:

```bash
pip install ".[pdf]"
```

`--to-epub` reads a PDF with [docling](https://github.com/docling-project/docling)'s layout and table models, so it needs more than the base install: PyTorch, the models, and Pandoc to build the EPUB. None of it is installed by default, because most people translate EPUBs and never open a PDF. No Java is needed; instructions that mention a JRE or Adoptium are out of date.

## 1. Get the code

The route is not in the published package yet, so you install from a clone.

```bash
git clone https://github.com/yihong0618/bilingual_book_maker.git
```

```bash
cd bilingual_book_maker
```

A virtual environment is recommended.

## 2. Pandoc

You need Pandoc **3.1.12 or newer** on PATH. It builds the EPUB and its navigation. Older releases point the table of contents at files instead of headings, and the tool refuses them before the PDF is opened. The apt packages on Ubuntu 24.04 and Debian 13 are older than this, so take a release from [pandoc.org/installing.html](https://pandoc.org/installing.html).

```bash
pandoc --version
```

## 3. The PDF extra

Install the extra rather than the locked requirements files: the locked files pin exact versions of PyTorch and everything else, so they download again a torch you may already have; the extra reuses what is installed.

Which PyTorch build you get is decided by the *index* it comes from, not by the version. Pick your system:

=== "macOS (Apple silicon)"

    ```bash
    pip install ".[pdf]"
    ```

    You get MPS acceleration. There is nothing to choose.

=== "Linux with NVIDIA"

    ```bash
    pip install ".[pdf]"
    ```

    PyPI's Linux wheel *is* the CUDA build, so this is all you need. The CUDA Toolkit is not needed; the wheel carries the runtime.

=== "Linux, CPU only"

    ```bash
    pip install ".[pdf]" \
        --extra-index-url https://download.pytorch.org/whl/cpu
    ```

    Without the CPU index you get about 3.2 GB of CUDA you cannot use. With it, about 380 MB, and no `nvidia-*` packages.

=== "Windows with NVIDIA"

    ```bat
    pip install ".[pdf]" ^
        --extra-index-url https://download.pytorch.org/whl/cu126
    ```

    **PyPI's Windows wheel is CPU-only.** Windows CUDA builds are published only on PyTorch's own index, so the plain line leaves you on the processor, silently. `cu128` is there for newer cards and drivers. The CUDA wheel is about 2.7 GB.

    You also need the NVIDIA driver. PyTorch bundles the CUDA runtime, so you do **not** need the CUDA Toolkit, but the driver is yours to install:

    - Driver download: <https://www.nvidia.com/en-us/drivers/> (Game Ready or Studio both work).
    - PyTorch's installer matrix, to confirm the channel for your card: <https://pytorch.org/get-started/locally/>.

    Check the driver before installing PyTorch:

    ```bat
    nvidia-smi
    ```

    It prints the driver version and the highest CUDA version it supports. If that number is below the channel you chose (12.6 for `cu126`), update the driver. An old driver is the usual reason `torch.cuda.is_available()` says `False` on a machine that has a card.

=== "Windows, CPU only"

    ```bat
    pip install ".[pdf]"
    ```

    On Windows this gives you the CPU build, because that is what PyPI ships there.

Use `--extra-index-url`, never `--index-url`. `--index-url` *replaces* PyPI, and everything else this tool needs would stop resolving.

If you want exactly the versions the project tests, `requirements-pdf-gpu.txt` and `requirements-pdf-cpu.txt` are the pinned sets the Docker images install (`pdf` the CPU file, `pdf-cuda` the GPU file minus the PyTorch its base already has; `pip install -r requirements-pdf-cpu.txt` names the CPU index inside the file). Expect them to replace the PyTorch you have.

### With uv

```bash
uv pip install ".[pdf]"
```

When you want the CPU build:

```bash
uv pip install ".[pdf]" --torch-backend=cpu
```

## 4. Run it

Read two pages first. `--test` translates only a few blocks.

```bash
python make_book.py \
  --book_name paper.pdf \
  --to-epub \
  --pages 1-2 \
  --test
```

Then the full run:

```bash
python make_book.py \
  --book_name paper.pdf \
  --to-epub \
  --use_context session
```

The models (about 500 MB) download on the first run, so the first PDF takes noticeably longer than the second. The progress line keeps running while they download. They come from Hugging Face, so `HF_HOME` moves the cache:

```bash
export HF_HOME=/path/with/room
```

A scan read with `--pdf-ocr` downloads nothing more: the pdf extra installs rapidocr with its models, and on a Mac also Apple's engine, ocrmac, which `auto` picks there. [Which OCR engine](features/pdf-ocr-engines.md) compares them.

Once cached, extraction needs no network. [Recommended settings for PDF](features/recommended-pdf.md) has the command for each kind of document.

## Check what you got

```bash
python -c "import torch; print(torch.__version__, torch.version.cuda)"
```

- A version ending in `+cpu`, and `None`: the CPU build. Correct for the CPU route.
- A CUDA version like `12.6`: a CUDA build. `torch.cuda.is_available()` then says whether this machine can use it. `False` means the build has CUDA but there is no usable card, or the driver is too old.
- On Apple silicon, `None` is correct: MPS is not CUDA. Check it with `python -c "import torch; print(torch.backends.mps.is_available())"`.
- On Windows with an NVIDIA card, `None` means you are on the CPU build, which is what PyPI ships there. Go back to step 3.

`--device cuda` tells these two failures apart, because they have different fixes: a CPU-only build is a reinstall; a machine without a card is not.

## Installing without a clone: not yet

`pip install "bbook_maker[pdf]"` does **not** install the route, and it does not fail either. pip treats an unknown extra as a warning, installs the last release without docling, and exits 0:

```text
WARNING: bbook-maker 1.2.1 does not provide the extra 'pdf'
Successfully installed bbook-maker-1.2.1
```

The next PDF run refuses with the missing-extra message. Until a release carries the route, clone the repository (step 1) and install `".[pdf]"` from it.

## Keeping the CPU build

`torch==…+cpu` satisfies any `torch>=…` requirement, so nothing forces it to be replaced. But the next `pip install -U` that touches PyTorch without the index will fetch the CUDA wheel and pull in about 3 GB. Make the index stick to the environment:

```bash
export PIP_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu
```

Or in `pip.conf` / `pip.ini`:

```ini
[global]
extra-index-url = https://download.pytorch.org/whl/cpu
```

The uv equivalent is `UV_TORCH_BACKEND=cpu`.

## Or skip all of it: Docker

The `pdf` image tag carries Pandoc and the whole docling runtime with PyTorch's CPU build: `docker pull ghcr.io/yihong0618/bilingual_book_maker:pdf`. With an NVIDIA card, the `pdf-cuda` tag. See [Docker](docker.md), including why a Mac should install natively instead.

## Sizes

What the PDF step downloads with PyTorch 2.7.1, resolved on each platform (102 packages):

| | download |
|---|---|
| macOS, Apple silicon | **~270 MB** |
| Linux x86_64, CPU build | **~380 MB** |
| Linux x86_64, CUDA build | **~3.2 GB** |
| Windows x86_64, PyPI — the CPU build | **~420 MB** |
| Windows x86_64, `cu126` | **~2.9 GB** |

Plus **~500 MB of models** on the first run, on every platform.

PyTorch is most of the variation. The rest of the tree is about 200 MB everywhere (opencv 48 MB, scipy 29 MB, rapidocr 27 MB, transformers, numpy, pandas…). The PyTorch wheel alone:

| | torch 2.7.1 wheel |
|---|---|
| macOS arm64 | 68.6 MB |
| Linux x86_64, `+cpu` | 175.8 MB, and its metadata declares **no** `nvidia-*` requirements |
| Linux x86_64, PyPI default | 821.0 MB, **plus ~2.16 GB** of `nvidia-*` and `triton` wheels |
| Windows x86_64, PyPI | 216.0 MB — the CPU build |
| Windows x86_64, `cu126` | 2.72 GB |

The two platforms are opposites, which is the trap: on Linux the default is CUDA and you opt *out*; on Windows the default is CPU and you opt *in*. macOS has no CUDA variant; its small PyTorch carries Metal kernels and no CUDA, and nothing is missing.

## If it does not work

These are the lines the tool prints, and what to do.

- **`reading a PDF needs the pdf extra, which is not installed.`** Step 3 was not done. If you ran `pip install "bbook_maker[pdf]"` and it said it succeeded, that is the trap described above.
- **`Pandoc is required for --to-epub. Install it and make sure pandoc is on PATH.`** Do step 2.
- **`… is too old for EPUB export; Pandoc 3.1.12 or newer is required`** Your Pandoc came from apt. Install the release from pandoc.org (step 2) and put it first on PATH; the message names the harness `tools/pdf_to_book.py --pandoc PATH` as the other way.
- **`--device cuda was asked for, but the installed PyTorch is a CPU-only build.`** Reinstall the extra with the CUDA build: the plain line on Linux, the `cu126` index on Windows (step 3).
- **`--device cuda was asked for, but this machine has no cuda accelerator available.`** The build has CUDA; the machine or driver cannot provide it. Run `nvidia-smi`: no output means no driver; a CUDA version lower than your channel means the driver is too old. Otherwise use `--device cpu`.
- **On Windows, `torch.version.cuda` is `None` although the machine has a card.** The plain line was used. Reinstall naming the `cu126` index (step 3).
- **`… selected pages have no text layer …; rerun with --pdf-ocr …`** The PDF is a scan. Add `--pdf-ocr`, and `--ocr-lang` if the scan is not in Chinese or English. See [Recommended settings for PDF](features/recommended-pdf.md).
- **In Docker, `--gpus all` seems ignored on an ARM machine.** It is: the arm64 image has a CPU-only PyTorch. Add `--platform linux/amd64`.
- **In Docker on a Mac, the GPU is never used.** Correct and unfixable: the Linux VM cannot see Metal. Install natively (steps 1 to 4) for MPS.

---

### En Installation (`docs/en/installation.md`)

# Installation

bilingual book maker is a Python program. It needs Python 3.10 or newer. A virtual environment keeps it apart from your other packages.

## From PyPI

```bash
pip install -U bbook_maker
```

This gives you the `bbook_maker` command. It translates EPUB, TXT, Markdown and SRT files, and PDFs on the older text route.

The published package does not carry the PDF-to-EPUB route yet. If you want that, install from a checkout (below) and add the [PDF extra](installation-pdf.md).

## From a checkout

Use a checkout if you want the newest code or the PDF-to-EPUB route.

```bash
git clone https://github.com/yihong0618/bilingual_book_maker.git
```

```bash
cd bilingual_book_maker
```

```bash
pip install -r requirements.txt
```

`requirements.txt` is the pinned, tested set. `pip install .` also works and takes whatever versions resolve today. From a checkout the command is `python make_book.py`, with the same flags as `bbook_maker`.

## The PDF extra

If you want to turn PDFs into bilingual EPUBs (`--to-epub`), add the PDF extra from the same checkout. It brings docling and PyTorch; the models (about 500 MB) download on the first run, and you also need Pandoc 3.1.12 or newer.

```bash
pip install ".[pdf]"
```

The extra reuses a PyTorch you already have instead of downloading a pinned one. That line is right on Apple silicon, on Linux with an NVIDIA GPU, and on Windows without one. Linux without an NVIDIA GPU adds PyTorch's CPU index, or it downloads about 3 GB of CUDA it cannot use; Windows with an NVIDIA GPU adds PyTorch's CUDA index. [Installing the PDF extra](installation-pdf.md) has the line for every case.

Do not run `pip install "bbook_maker[pdf]"`. The published package does not carry the route yet: pip only warns and installs the release without it.

## Docker

If you would rather not install Python packages, use the published image. See [Docker](docker.md).

## Check the install

```bash
bbook_maker --help
```

From a checkout:

```bash
python make_book.py --help
```

The help text is the authority on every flag. [Command line options](cmd.md) lists them in one table.

---

### En Languages (`docs/en/languages.md`)

# Languages

`--language` takes any row of this table either way: the tag
(`--language zh-hant`) or the name (`--language "traditional chinese"`,
case-insensitive). The tag is the mechanical half — stamped on the
inserted markup and `dc:language`, recorded in the translation metadata record,
and it names the structured-output reply field (`zh_hant_translation`).
The name is the prose half: what the model is actually asked for.

For a language this table misses, pass both halves yourself as
`TAG:NAME`, split on the first colon — for example
`--language "ain:Ainu"`. The tag then drives the stamps and
field names exactly as if it were listed here; the name goes to the
model. A free-typed name matching nothing still runs — the run prints
one `Note:` line saying nothing will be stamped on the output.

The source language is never part of `--language`; `--source_lang`
states it when auto-detection isn't enough.

## Tags

| Tag | Name |
| --- | --- |
| `aa` | afar |
| `ab` | abkhaz |
| `ace` | acehnese |
| `af` | afrikaans |
| `ak` | akan |
| `akk` | akkadian |
| `am` | amharic |
| `an` | aragonese |
| `ang` | old english |
| `apc` | levantine arabic |
| `ar` | arabic |
| `arc` | aramaic |
| `arn` | mapudungun |
| `ary` | moroccan arabic |
| `arz` | egyptian arabic |
| `as` | assamese |
| `ast` | asturian |
| `av` | avar |
| `ay` | aymara |
| `az` | azerbaijani |
| `ba` | bashkir |
| `ban` | balinese |
| `bcl` | bikol |
| `be` | belarusian |
| `bg` | bulgarian |
| `bho` | bhojpuri |
| `bi` | bislama |
| `bm` | bambara |
| `bn` | bengali |
| `bo` | tibetan |
| `br` | breton |
| `brx` | bodo |
| `bs` | bosnian |
| `bug` | buginese |
| `ca` | catalan |
| `ce` | chechen |
| `ceb` | cebuano |
| `ch` | chamorro |
| `chr` | cherokee |
| `ckb` | central kurdish |
| `co` | corsican |
| `cop` | coptic |
| `cr` | cree |
| `cs` | czech |
| `csb` | kashubian |
| `cu` | church slavonic |
| `cv` | chuvash |
| `cy` | welsh |
| `da` | danish |
| `de` | german |
| `de-at` | austrian german |
| `de-ch` | swiss standard german |
| `doi` | dogri |
| `dsb` | lower sorbian |
| `dv` | dhivehi |
| `dz` | dzongkha |
| `ee` | ewe |
| `egy` | ancient egyptian |
| `el` | greek |
| `en` | english |
| `en-au` | australian english |
| `en-ca` | canadian english |
| `en-gb` | british english |
| `en-in` | indian english |
| `en-us` | american english |
| `enm` | middle english |
| `eo` | esperanto |
| `es` | spanish |
| `es-419` | latin american spanish |
| `es-ar` | argentine spanish |
| `es-es` | european spanish |
| `es-mx` | mexican spanish |
| `et` | estonian |
| `eu` | basque |
| `fa` | persian |
| `ff` | fulah |
| `fi` | finnish |
| `fil` | filipino |
| `fj` | fijian |
| `fo` | faroese |
| `fr` | french |
| `fr-ca` | canadian french |
| `fro` | old french |
| `fur` | friulian |
| `fy` | western frisian |
| `ga` | irish |
| `gd` | scottish gaelic |
| `gl` | galician |
| `gn` | guarani |
| `goh` | old high german |
| `got` | gothic |
| `grc` | ancient greek |
| `gsw` | swiss german |
| `gu` | gujarati |
| `gv` | manx |
| `ha` | hausa |
| `hak` | hakka |
| `haw` | hawaiian |
| `he` | hebrew |
| `hi` | hindi |
| `hil` | hiligaynon |
| `hit` | hittite |
| `hr` | croatian |
| `hsb` | upper sorbian |
| `ht` | haitian creole |
| `hu` | hungarian |
| `hy` | armenian |
| `ia` | interlingua |
| `id` | indonesian |
| `ie` | interlingue |
| `ig` | igbo |
| `ilo` | ilocano |
| `io` | ido |
| `is` | icelandic |
| `it` | italian |
| `iu` | inuktitut |
| `ja` | japanese |
| `jam` | jamaican patois |
| `jbo` | lojban |
| `jw` | javanese |
| `ka` | georgian |
| `kab` | kabyle |
| `kam` | kamba |
| `kbd` | kabardian |
| `kg` | kongo |
| `ki` | kikuyu |
| `kk` | kazakh |
| `kl` | greenlandic |
| `km` | khmer |
| `kn` | kannada |
| `ko` | korean |
| `kok` | konkani |
| `ks` | kashmiri |
| `ku` | kurdish |
| `kv` | komi |
| `kw` | cornish |
| `ky` | kyrgyz |
| `la` | latin |
| `lad` | ladino |
| `lb` | luxembourgish |
| `lez` | lezgian |
| `lg` | ganda |
| `lij` | ligurian |
| `lld` | ladin |
| `lmo` | lombard |
| `ln` | lingala |
| `lo` | lao |
| `lt` | lithuanian |
| `lu` | luba-katanga |
| `luo` | luo |
| `lv` | latvian |
| `lzh` | literary chinese |
| `mad` | madurese |
| `mai` | maithili |
| `mer` | meru |
| `mg` | malagasy |
| `mi` | maori |
| `min` | minangkabau |
| `mk` | macedonian |
| `ml` | malayalam |
| `mn` | mongolian |
| `mni` | manipuri |
| `mr` | marathi |
| `ms` | malay |
| `mt` | maltese |
| `mwl` | mirandese |
| `my` | myanmar |
| `myv` | erzya |
| `nah` | nahuatl |
| `nan` | hokkien |
| `nap` | neapolitan |
| `nb` | norwegian bokmal |
| `nds` | low german |
| `ne` | nepali |
| `new` | newari |
| `nl` | dutch |
| `nl-be` | belgian dutch |
| `nn` | nynorsk |
| `no` | norwegian |
| `non` | old norse |
| `nr` | southern ndebele |
| `nso` | northern sotho |
| `nv` | navajo |
| `ny` | chichewa |
| `nyn` | nyankole |
| `oc` | occitan |
| `oj` | ojibwe |
| `om` | oromo |
| `or` | odia |
| `os` | ossetian |
| `pa` | punjabi |
| `pal` | middle persian |
| `pam` | kapampangan |
| `pap` | papiamento |
| `pi` | pali |
| `pl` | polish |
| `pms` | piedmontese |
| `ps` | pashto |
| `pt` | portuguese |
| `pt-br` | brazilian portuguese |
| `pt-pt` | european portuguese |
| `qu` | quechua |
| `rm` | romansh |
| `rn` | kirundi |
| `ro` | romanian |
| `ru` | russian |
| `rue` | rusyn |
| `rw` | kinyarwanda |
| `sa` | sanskrit |
| `sah` | yakut |
| `sat` | santali |
| `sc` | sardinian |
| `scn` | sicilian |
| `sco` | scots |
| `sd` | sindhi |
| `se` | northern sami |
| `sg` | sango |
| `sga` | old irish |
| `shn` | shan |
| `si` | sinhala |
| `sk` | slovak |
| `sl` | slovenian |
| `sm` | samoan |
| `sn` | shona |
| `so` | somali |
| `sq` | albanian |
| `sr` | serbian |
| `sr-cyrl` | serbian cyrillic |
| `sr-latn` | serbian latin |
| `srn` | sranan tongo |
| `ss` | swati |
| `st` | southern sotho |
| `su` | sundanese |
| `sux` | sumerian |
| `sv` | swedish |
| `sw` | swahili |
| `syr` | syriac |
| `szl` | silesian |
| `ta` | tamil |
| `te` | telugu |
| `tg` | tajik |
| `th` | thai |
| `ti` | tigrinya |
| `tk` | turkmen |
| `tl` | tagalog |
| `tn` | tswana |
| `to` | tongan |
| `tpi` | tok pisin |
| `tr` | turkish |
| `ts` | tsonga |
| `tt` | tatar |
| `ty` | tahitian |
| `tyv` | tuvan |
| `tzm` | central atlas tamazight |
| `udm` | udmurt |
| `ug` | uyghur |
| `uk` | ukrainian |
| `umb` | umbundu |
| `ur` | urdu |
| `uz` | uzbek |
| `ve` | venda |
| `vec` | venetian |
| `vi` | vietnamese |
| `vo` | volapuk |
| `wa` | walloon |
| `war` | waray |
| `wo` | wolof |
| `wuu` | wu chinese |
| `xcl` | classical armenian |
| `xh` | xhosa |
| `yi` | yiddish |
| `yo` | yoruba |
| `yua` | yucatec maya |
| `zh` | chinese |
| `zh-cn` | mainland chinese |
| `zh-hans` | simplified chinese |
| `zh-hant` | traditional chinese |
| `zh-hk` | hong kong chinese |
| `zh-sg` | singapore chinese |
| `zh-tw` | taiwan mandarin |
| `zh-yue` | cantonese |
| `zu` | zulu |

## Other spellings

Names that are not the one printed above, accepted for the same tag.

| You may type | Tag |
| --- | --- |
| `bahasa indonesia` | `id` |
| `bahasa melayu` | `ms` |
| `bokmal` | `nb` |
| `brazilian` | `pt-br` |
| `burmese` | `my` |
| `castilian` | `es` |
| `chewa` | `ny` |
| `classical chinese` | `lzh` |
| `divehi` | `dv` |
| `farsi` | `fa` |
| `flemish` | `nl` |
| `frisian` | `fy` |
| `haitian` | `ht` |
| `irish gaelic` | `ga` |
| `isixhosa` | `xh` |
| `isizulu` | `zu` |
| `jamaican creole` | `jam` |
| `judeo-spanish` | `lad` |
| `kalaallisut` | `kl` |
| `kirghiz` | `ky` |
| `letzeburgesch` | `lb` |
| `maldivian` | `dv` |
| `mandarin` | `zh` |
| `mandarin chinese` | `zh` |
| `min nan` | `nan` |
| `modern standard arabic` | `ar` |
| `moldavian` | `ro` |
| `moldovan` | `ro` |
| `norwegian nynorsk` | `nn` |
| `nyanja` | `ny` |
| `old church slavonic` | `cu` |
| `oriya` | `or` |
| `panjabi` | `pa` |
| `pilipino` | `fil` |
| `pushto` | `ps` |
| `putonghua` | `zh` |
| `scots gaelic` | `gd` |
| `sesotho` | `st` |
| `setswana` | `tn` |
| `shanghainese` | `wuu` |
| `sinhalese` | `si` |
| `sorani` | `ckb` |
| `standard chinese` | `zh` |
| `swiss high german` | `de-ch` |
| `taiwanese hokkien` | `nan` |
| `twi` | `ak` |
| `uighur` | `ug` |
| `valencian` | `ca` |

---

### En Llm Args (`docs/en/llm-args.md`)

# Translating with an LLM

If you have an OpenAI key, the default route needs nothing else:

```bash
export OPENAI_API_KEY=sk-...
bbook_maker --book_name my_book.epub --use_context session
```

This page covers the flags that decide *where* the requests go and *what* they ask for. They work the same for every input format. What each format adds is on the [Formats](formats/epub.md) pages.

## The route: model, endpoint, format

A route is an endpoint, not a model name. Three flags name it.

| flag | what it does |
|---|---|
| `--model` (`-m`) | The model id, exactly as the endpoint names it: `gpt-5-mini`, `claude-sonnet-4-6`, `openai/gpt-5-mini`. Default `gpt-6-luna` on the `openai` format. The `anthropic` format needs one. |
| `--api_base` | The endpoint URL. Defaults to the format's official host. An OpenAI-shaped endpoint ends in `/v1`. A pasted `…/v1/chat/completions` or a trailing slash is trimmed. |
| `--api_format` | The API the endpoint speaks: `openai`, `anthropic`, `codex`, `gemini`, `qwen`, `groq`, `xai`, `litellm`, or a [translation service](machine-args.md). Inferred from `--api_base` when you leave it out: an Anthropic host means `anthropic`, everything else `openai`. A `claude-*` model id with no `--api_base` also selects `anthropic`. |

Examples, one per common case:

=== "OpenAI"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --model gpt-6-luna \
      --use_context session
    ```

=== "Anthropic"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --model claude-sonnet-4-6 \
      --use_context session
    ```

=== "Any OpenAI-compatible endpoint"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_base https://gateway.example.com/v1 \
      --model provider/model-id \
      --use_context session
    ```

=== "Gemini"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format gemini \
      --model gemini-flash-latest \
      --use_context
    ```

    Gemini keeps its own chat history, so it takes window mode (`--use_context`), not a session. `--interval` sets the pause between requests; this is how you stay under the free tier's rate limit.

=== "Codex (ChatGPT plan)"

    ```bash
    python make_book.py \
      --book_name my_book.epub \
      --api_format codex
    ```

    Spends your ChatGPT plan through the [Codex CLI](https://developers.openai.com/codex/cli), which must be installed and signed in (`codex login`). It runs `gpt-6-luna`; add `--model <id>` for another. The thread is the context, so no `--use_context` is needed. It ignores `--api_base` and `--key`. When Codex answers that the selected model is at capacity, the run prints a `codex: … retrying in 60 s` line and asks again; that answer is usually Codex rate-limiting a network it distrusts, and another network or account helps.

A gateway that serves Claude models usually speaks the OpenAI shape. If a gateway answers 404 to the anthropic shape, the run stops and names `--api_format openai` as the fix.

`--api_format groq`, `xai` and `litellm` are the OpenAI shape at Groq, xAI and a LiteLLM proxy (`http://localhost:4000`). Each knows its address, so the format, a key and `--model` are the whole route. `--model` is required there.

`--model orcarouter` sends the run to the OrcaRouter gateway and its `orcarouter/auto` model. The key comes from `--key` or `BBM_ORCAROUTER_API_KEY`.

## One command per vendor

If your vendor is listed here, this is the whole command (the key can also come from the environment, see [Keys](#keys)):

```
/* Detailed source-code truncated for AI context efficiency. */
```

Translation services (Google, DeepL, Caiyun, Tencent, a custom API) are on [Translation services](machine-args.md). A vendor you use often belongs in the [provider file](providers.md).

## Keys

The key is looked up in this order:

1. `--key` (`--api_key` is the same flag). Several comma-separated keys are rotated, to go past per-key rate limits.
2. `BBM_API_KEY`.
3. The format's own variable: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `BBM_GOOGLE_GEMINI_KEY`, `BBM_QWEN_API_KEY`, `BBM_GROQ_API_KEY`, `BBM_XAI_API_KEY`.

An endpoint on localhost needs no key. A key on the command line is visible to other users of the machine in the process list; prefer the environment. The tool does not read `.env` files itself. Load one first:

```bash
set -a; source .env; set +a
```

More variables are on [Environment variables](env_settings.md).

## The provider file and extra models

`--provider NAME` takes the route from a JSON file instead of flags: `bbm_providers.json` in the directory you run from, or `~/.bbm/providers.json`. A name in neither falls back to the shipped `bbm_providers.example.json`, and the run says so.

```bash
bbook_maker \
  --book_name my_book.epub \
  --provider siliconflow \
  --use_context session
```

Two steps can use a model other than the translating one:

| flag | what it does |
|---|---|
| `--classify-model MODEL` | The model that decides an EPUB's plan, or a Jev-compatible classifier (`jev` for TypeSafe's). Default: the entry's `classify_model`, else the translating model. `--plan-classify-model` is its old name. |
| `--classify-base-url URL`, `--classify-key KEY` | Where that model is served and its key, when it is not the run's endpoint: an OpenAI-compatible address, or a Jev-compatible classifier's URL. |
| `--img-model MODEL` | A vision model for the steps that look at a page image (today: correcting region roles on the [PDF route](features/pdf-to-epub.md)). Default: the entry's `img_model`, else off. Never the translating model by fallback. `none` turns an entry's image model off. |
| `--img-base-url URL`, `--img-key KEY` | Where that model is served and its key (OpenAI-compatible only). |

The fields, the shipped entries, which key is sent where, and Jev and Jev-compatible classifiers are on [Provider file and extra models](providers.md). Note that the shipped `openai` entry names an image model, so `--provider openai` turns the PDF route's image step on.

## Language

| flag | what it does |
|---|---|
| `--language` | The target language. A tag (`zh-hant`), a name (`"Traditional Chinese"`), or `TAG:NAME` when the tables miss the language (`--language "zh-hant:Traditional Chinese"`). The tag is stamped on the output; the name is what the model is asked for. Default `zh-hans`. The full list is on [Language tags](languages.md). |
| `--source_lang` | The source language, stated rather than detected. Named in the prompt on every LLM route. Default: detect. |

## The prompt

`--prompt` takes a template string, a JSON string, or a `.json`, `.txt` or `.md` file. It has three sections:

- `user`: the template. Required. It must contain `{text}`; `{language}` and `{crlf}` are filled in too. Any other placeholder is refused when the run starts.
- `system`: the system message.
- `style`: a note on register and voice. It is said once where a context window starts, and handed on to every window in session mode.

A bare string or a `.txt` file is the `user` template. The run prints which sections it used and where. The format is on [Prompt files](prompt.md).

```bash
bbook_maker \
  --book_name my_book.epub \
  --prompt prompt_template.json \
  --use_context session
```

## Test runs and resuming

| flag | what it does |
|---|---|
| `--test` | Translate only the first paragraphs, to check the setup. |
| `--test_num N` | How many (default 10). |
| `--resume` | Continue an interrupted run from its checkpoint. An EPUB checkpoint records the language, prompt and model; a mismatch stops the resume. |

Always run `--test` once on a new endpoint. It costs almost nothing and shows you the output format and the route the run chose.

## Retries: what "patient" means

Many people run this tool against providers that are slow or rate-limited, where the first token or a 429 backoff can take minutes. So the tool never gives up on an error that could clear by waiting.

- A timeout, a dropped connection, a 429 or a 5xx is retried with no attempt limit. The wait starts at 1 second, doubles, and stops growing at 5 minutes.
- Every retry prints one line, so a long wait does not look like a hang:
  `retrying after RateLimitError (…) — attempt 4, waiting 8s`
- An error that will not clear by waiting stops the run at once: a rejected key, a permission error, a request the endpoint refuses, a model that does not exist.
- Ctrl+C always stops the run, even during a long wait. The progress is saved; rerun with `--resume`.

If you see the same retry line for a long time, the provider is down or rate-limiting you. The run will continue by itself when it comes back.

## On-device models (Ollama, llama.cpp, LM Studio)

A local model is an OpenAI-compatible endpoint on your own machine. Point `--api_base` at it; no key is needed.

```bash
bbook_maker \
  --book_name my_book.epub \
  --api_base http://localhost:11434/v1 \
  --model qwen3:8b \
  --use_context session
```

What to expect from an 8B or 16B model:

- **No strict JSON schema.** Most local servers do not enforce one. The run notices this with a one-request probe and switches to a delimiter format, printing a line such as `doesn't apply JSON schema … using delimiter method`. This is not an error.
- **Smaller requests.** On an endpoint without a strict schema, each request carries at most 8 units, half of the default 16, and outside session mode about 800 tokens of text. The run prints the numbers it chose. The defaults are chosen so that a small model can hold them.
- **Plan classification by one word.** Plan mode asks the model `skip` or `translate` for five kinds of block per turn. If the model misses the reply format twice in a row, the run asks three per turn, then one. If it still misses, classification stops and the rest is translated. Anything unreadable is treated as `translate`, so a weak model translates too much rather than too little. You can also hand classification to a hosted model with `--classify-model` and keep translating locally; see [Provider file and extra models](providers.md#examples).
- **Short context.** Set `--context-compact-at` to the model's input limit (minimum 1500) so a session window never overflows it.
- **Thinking models.** A model that reasons before it answers can be told not to with `--no-thinking`. The run finds the request field the server accepts from the server's own rejections. If your server needs a field of its own, `--extra_body` wins, for example `--extra_body '{"chat_template_kwargs": {"enable_thinking": false}}'`.
- **No image step.** The PDF route's image step runs only on a model you name with `--img-model`, so a local model is never asked to read a page.

Flags to avoid on a small model:

- `--glossary-auto on`. It needs a model that answers with names, not prose.
- Raising `--accumulated_num` or `--max-batch-units`. Lower them instead when the run prints `misaligned batches this run`.
- `--model_list`. Each model keeps its own cache, and mixing models in one conversation hurts consistency.

If plan classification keeps failing on your model, `--plan-classify all` skips it and translates every block. See [Plan mode](features/plan-mode.md).

## Other request options

| flag | what it does |
|---|---|
| `--temperature` | Sampling temperature. The anthropic format always sends it; the openai format leaves it out when it equals the API default or when the model rejects one; codex ignores it. |
| `--no-thinking` | Ask the model not to reason before answering; thinking buys nothing on a paragraph and costs tokens and time. On the OpenAI-shaped routes the request field is found from the endpoint's own rejections and remembered; if the endpoint refuses every spelling, the run warns once and goes on without it. On anthropic it is `thinking: {"type": "disabled"}`. Refused on codex. Image requests carry it too. |
| `--extra_body JSON` | Extra fields on every request body (openai and anthropic routes). A field here beats the flag for it, `--no-thinking` included. |
| `--extra_headers JSON` | Extra HTTP headers on every request (openai and anthropic routes). |
| `--model_list IDS` | Several models to rotate across, to spread rate limits. Refused with `--use_context session`. |
| `--interval SECONDS` | Pause between requests. Only the gemini format uses it. |
| `-p`, `--proxy URL` | An HTTP proxy for the run, for example `http://127.0.0.1:7890`. |
| `--batch`, `--batch-use` | OpenAI's Batch API: submit a job, then build the book from it later. Not on EPUB. |

Common `--extra_body` and `--extra_headers` values:

```bash
# openai route: turn off a local/vLLM chat template's thinking block
--extra_body '{"chat_template_kwargs": {"enable_thinking": false}}'
# openai route (chat completions): reasoning effort and a token ceiling, neither of which has a flag
--extra_body '{"reasoning_effort": "low", "max_completion_tokens": 2000}'
# anthropic route: keep extended thinking off
--extra_body '{"thinking": {"type": "disabled"}}'
# OpenRouter attribution, shown on its dashboard
--extra_headers '{"HTTP-Referer": "https://example.com", "X-Title": "bilingual_book_maker"}'
# a gateway's own auth or routing header
--extra_headers '{"X-API-Key": "sk-gateway-..."}'
```

---

### En Machine Args (`docs/en/machine-args.md`)

# Translation services

These routes call a translation service: text goes in, a translation comes back. Some of these services run a language model behind their API, but none takes a prompt from this tool. They need no model and often no key. They also cannot answer a question, so everything in this tool that asks the model something is off on them.

## The routes

Pick one with `--api_format`.

| `--api_format` | service | key | target languages |
|---|---|---|---|
| `google` | Google Translate's public web endpoint | none | most; the source is detected |
| `deepl` | DeepL through the RapidAPI [DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator) | `--key`, or `BBM_DEEPL_API_KEY` | DeepL's list; others are refused with `DeepL do not support …` |
| `deeplfree` | DeepL's free web endpoint | none | DeepL's list |
| `caiyun` | [Caiyun](https://fanyi.caiyunapp.com) | `--key`, or `BBM_CAIYUN_API_KEY` | Simplified Chinese, English, Japanese |
| `tencent` | [Tencent TranSmart](https://transmart.qq.com) | none | Chinese or English |
| `customapi` | your own HTTP service | none; the address goes in `--api_base` | whatever your service does |

Both DeepL routes send the source as English. Use them for English books.

Caiyun publishes a test token, `3975l6lr5pcbvidl6jl2`, for trying the route; for your own, follow [this tutorial](https://bobtranslate.com/service/translate/caiyun.html).

## Commands

=== "Google"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format google \
      --language zh-hant
    ```

=== "DeepL"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format deepl \
      --key ${deepl_key} \
      --language ja
    ```

=== "DeepL free"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format deeplfree \
      --language de
    ```

=== "Caiyun"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format caiyun \
      --key ${caiyun_key} \
      --language zh-hans
    ```

=== "Tencent"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format tencent \
      --language zh-hans
    ```

=== "Custom API"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format customapi \
      --api_base https://your.host/translate \
      --language ja
    ```

## The custom API contract

The tool sends one POST per paragraph to `--api_base`, form-encoded, with three fields: `text`, `source_lang` and `target_lang`. It reads the translation from the `data` field of the JSON answer. Each request times out after 10 seconds, and the tool waits 5 seconds between requests. `--source_lang` reaches your service here.

## What these routes cannot do

- **No prompt.** `--prompt` has no place to go; the text is sent alone.
- **No session.** `--use_context session` is refused: there is no conversation to keep.
- **No plan classification by the engine itself.** On an EPUB the run falls back to the `--translate-tags` selection (`p` by default), so verse or tables outside `<p>` stay untranslated. Name an LLM classifier and the book gets a full plan while the engine translates: `--classify-model gpt-6-luna` (it reads `OPENAI_API_KEY`), or a provider entry's `classify_model`. This was run end to end on Google Translate. Without one, `--plan-classify model` is refused, and `--plan-classify all` still translates every block the plan finds without asking anyone. See [Plan mode](features/plan-mode.md#choosing-the-classifier).
- **No glossary.** `--glossary` is read only by the OpenAI- and Codex-shaped routes; here it is ignored with a warning.
- **No stated source language** on `google`, `deepl`, `deeplfree`, `caiyun` and `tencent`. `--source_lang` is ignored with a warning; only `customapi` (and the `qwen` LLM route) send it.

Everything else works: `--test`, `--resume`, `--single_translate`, the per-format flags on the [Formats](formats/epub.md) pages.

## Rate limits

Caiyun prints `will sleep 60s for the time limit` when it is rate-limited, and waits. DeepL free and Tencent pause between requests on their own. The [patient retry](llm-args.md#retries-what-patient-means) policy applies to every route.

---

### En Migration (`docs/en/migration.md`)

# Migrating from the old flags

Commands written for the old CLI keep working. Each removed flag is rewritten
into the new flags before the run starts, and a line says what it became:

```
$ bbook_maker --book_name book.epub --model gpt4omini --openai_key sk-...
deprecated: --openai_key is now --key
deprecated: --model gpt4omini is now --model gpt-4o-mini
```

| Old | Rewritten to |
|---|---|
| `--model gpt4o` / `gpt4omini` / `o3mini` | `--model` with that model's id |
| `--model chatgptapi` / `openai` | dropped: the openai format is the default, and `--model` names a model when you want one |
| `--model openai --model_list X` | `--model_list X` |
| `--model claude` | `--model claude-haiku-4-5-20251001` |
| an exact `claude-*` id | unchanged; the anthropic format is inferred from the id |
| `--model gemini` / `geminipro` | `--api_format gemini --model gemini-flash-latest` / `gemini-pro-latest` |
| `--model qwen` / `qwen-mt-turbo` / `qwen-mt-plus` | `--api_format qwen --model qwen-mt-*` |
| `--model groq --model_list X` | `--api_format groq --model_list X` |
| `--model xai` | `--api_format xai --model grok-4.3` |
| `--model codex` | `--api_format codex` (the sidecar's default model; add `--model` to name one) |
| `--model google` / `caiyun` / `deepl` / `deeplfree` / `tencentransmart` | `--api_format google` / `caiyun` / `deepl` / `deeplfree` / `tencent` |
| `--custom_api URL` | `--api_format customapi --api_base URL` |
| `--openai_key` / `--claude_key` / `--gemini_key` / `--groq_key` / `--xai_key` / `--qwen_key` / `--caiyun_key` / `--deepl_key` / `--orcarouter_key` | `--key` (`--api_key` is the same flag and was never renamed) |
| `--ollama_model M` | `--api_base http://localhost:11434/v1 --model M` |
| `--deployment_id D` | `--model D`, with `--api_base` rewritten to the deployment's `/openai/v1` path |

Notes:

- The old key variables still work for the route that used them:
  `BBM_GROQ_API_KEY` for a rewritten `--model groq`, `BBM_GOOGLE_GEMINI_KEY`
  for a rewritten `--model gemini`, and so on.
- Flags you pass yourself win. `--model gemini --api_base https://my-gateway/v1`
  keeps your gateway.
- `--interval` is not a legacy flag: it is still in the parser and still
  paces the gemini route, which is still a route.
- `qwen-mt-turbo` and `qwen-mt-plus` are real model ids, so a command that
  also passes `--api_format` is left alone and nothing is printed about it.
- The other route aliases are not model ids anywhere. `--model gemini`
  together with an `--api_format` naming a different route is refused rather
  than resolved one way or the other: honouring the format would send that
  alias's key to a host it does not belong to, and honouring the alias would
  ignore what you typed. The error names both and the two ways out.
- A rewritten command runs the model it used to run, taken from the old
  preset list, not a newer one. Some of those models have since been
  retired, and the endpoint's model check says so.
- A `--model` value that is not an old alias passes through as a model id,
  which is the normal case now.
- The aliases for retired OpenAI models (`gpt4`, `gpt5mini`, `o1`, `o1mini`,
  `o1preview`) are gone. They now pass through as model ids and the endpoint
  rejects them by name, which is the same failure one step earlier and with
  a clearer message.

---

### En Model Lang (`docs/en/model_lang.md`)

# Endpoints, models and languages

A route is chosen by the endpoint it talks to, not by a model name. There is
no built-in model list to keep up to date: `--model` takes whatever id
your endpoint serves.

```sh
bbook_maker --book_name book.epub \
  --api_base https://api.openai.com/v1 --key sk-... \
  --model gpt-5-mini --language ja
```

Old `--model` commands still work — they are rewritten into these flags and
the substitution is printed. [Migrating from the old flags](migration.md)
has the full table.

## The route flags

| Flag | Meaning |
|---|---|
| `--model` | Model id, exactly as the endpoint names it. On the `openai` format it may be left out: it defaults to `gpt-6-luna`. |
| `--api_base` | Endpoint URL. Defaults to the format's official host; `…/v1`, `…/v1/` and `…/v1/chat/completions` all work. |
| `--key` | API key. Comma-separate several to rotate them and spread rate limits. |
| `--api_format` | Wire format. Inferred; pass it only when the guess is wrong. |
| `--provider` | A named endpoint from a provider file, standing in for the four above. |

`--model_list a,b` rotates across several models and is also what older
commands used; name a model in one flag or the other, not both.

`--api_format` is one of `openai` (default), `anthropic`, `gemini`, `qwen`,
`groq`, `xai`, `litellm`, `codex`, or the fixed translation services
`google`, `caiyun`, `deepl`, `deeplfree`, `tencent`, `customapi`.

The five vendor formats each carry their own endpoint, so the format and a
key are a whole route with no `--api_base` to look up:

| Format | Endpoint | `--model` |
|---|---|---|
| `gemini` | the Gemini API | optional, default `gemini-flash-latest` |
| `qwen` | Qwen-MT on DashScope | optional, default `qwen-mt-turbo` |
| `groq` | `https://api.groq.com/openai/v1` | required |
| `xai` | `https://api.x.ai/v1` | required |
| `litellm` | `http://localhost:4000` | required, the name in the proxy's config |

`gemini` and `qwen` are protocols of their own — Gemini's native
constrained decoding, its safety settings and chat history; Qwen-MT's
source/target language pair in place of a prompt — and `--interval` paces
the gemini route, which is how the free tier's rate limit is stayed under.
The other three are the OpenAI route at another address and keep everything
it has. None of the five is ever inferred from a host: name the format, or
give the vendor's OpenAI-compatible `--api_base` and get the `openai` route.

`codex` is not an endpoint at all: it drives a local `codex app-server`
sidecar and bills the run to your ChatGPT plan, so it takes no `--key` and no
`--api_base`, and `--model` is optional (default `gpt-6-luna`). It is never
inferred; name it explicitly. See [Translating with an LLM](llm-args.md).

Inference goes in this order: an explicit `--api_format` wins; then the
`--api_base` host (`anthropic.com` means the anthropic shape, anything else
the OpenAI shape); then, when no endpoint was named, a model id mentioning
`claude` or `anthropic`, `anthropic/claude-sonnet-4-6` included.

A gateway asked for the anthropic shape it does not serve answers 404 or
405 on `/v1/messages`. The run stops and names the fix: rerun with
`--api_format openai`. On Anthropic's own host a 404 means the model does
not exist and is reported as such.

Credentials come from `--key`, then `BBM_API_KEY`, then the format's
conventional variable — see [Environment settings](./env_settings.md). An
endpoint on localhost needs no key.

## Named endpoints: `--provider`

An endpoint used more than once can be written down instead of retyped.
`bbm_providers.json` in the working directory is read first, then
`~/.bbm/providers.json`; a project entry wins on a shared name.

```json
{
  "nvidia": {
    "api_style": "openai",
    "base_url": "https://integrate.api.nvidia.com/v1",
    "default_models": ["moonshotai/kimi-k2-thinking"],
    "env_key": "NVIDIA_API_KEY"
  }
}
```

```sh
bbook_maker --book_name book.epub --provider nvidia --language ja
```

`api_style` is any `--api_format` that names an endpoint — `openai`,
`anthropic`, `gemini`, `qwen`, `groq`, `xai`, `litellm` — plus `claude` as
the older spelling of `anthropic`. Any other OpenAI-compatible host is
`openai` with its address in `base_url`. The shipped
`bbm_providers.example.json` has an entry for each. Explicit flags still win over the entry, and the entry's `env_key` travels
exactly as far as the entry's address does. Asking for another wire format
at the entry's own gateway moves nothing, so the key is still read. An
`--api_base` pointing somewhere else does move the run, and so does an
`--api_format` override on an entry with no `base_url`, since there the
format is what supplies the address — the key is not read for another
vendor's host, and the run says which two addresses it is comparing. Pass
`--key` when you meant to reuse it. `default_models` supplies `--model` when it
holds one id and `--model_list` when it holds several, and `env_key` names
the variable to read the key from; no secret goes in the file. Anything
passed explicitly wins, so `--provider nvidia --model <id>` keeps that
model. An unknown name is an error naming both files.

## OrcaRouter

```sh
bbook_maker --book_name book.epub --model orcarouter --language ja
```

`--model orcarouter` sends the run to the OrcaRouter gateway and asks for
its smart-routing model, `orcarouter/auto`. It needs no `--api_base`, and
one you pass yourself wins. The key is read from `BBM_ORCAROUTER_API_KEY`
before the usual fallbacks. It is a supported route, not a legacy alias, so
nothing is rewritten. To pin one model at the gateway, name the endpoint
like any other: `--api_base https://api.orcarouter.ai/v1 --model <id>`.

## OpenAI-compatible endpoints

Everything below is the same route with a different `--api_base`. Structured
output, `--use_context`, parallel workers, async and the Batch API are
available on all of them to the extent the endpoint itself supports them —
support is probed at runtime rather than assumed from the model name.

`--use_context session` also needs the endpoint to charge less for cached
prompt tokens. Watch the `cached=` count on the progress bar: still zero
after a dozen requests means the endpoint is not caching, and window mode
is cheaper.

| Vendor | `--api_base` |
|---|---|
| OpenAI | `https://api.openai.com/v1` (default) |
| Groq | `https://api.groq.com/openai/v1`, or `--api_format groq` |
| xAI | `https://api.x.ai/v1`, or `--api_format xai` |
| Gemini (compatibility mode) | `https://generativelanguage.googleapis.com/v1beta/openai/` |
| Alibaba Qwen (DashScope) | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
| SiliconFlow | `https://api.siliconflow.cn/v1` |
| OpenRouter | `https://openrouter.ai/api/v1` |
| Azure OpenAI | the deployment's OpenAI-compatible URL; `--model` names the deployment |
| Ollama | `http://localhost:11434/v1` |
| vLLM / LM Studio / llama.cpp | whatever host they serve |

```sh
bbook_maker --book_name book.epub \
  --api_base https://api.groq.com/openai/v1 --key gsk-... \
  --model llama-3.3-70b-versatile
```

`--extra_body` passes vendor-specific request fields on this route.

## Anthropic

```sh
bbook_maker --book_name book.epub \
  --api_base https://api.anthropic.com --key sk-ant-... \
  --model claude-sonnet-4-6 --language zh-hans
```

Any model id the endpoint serves is accepted. Claude uses one model per run,
so extra `--model_list` entries are announced and ignored rather than
silently dropped. A gateway that serves the anthropic shape from an
OpenAI-style `/v1` base is handled: the trailing `/v1` is trimmed, because
the SDK appends its own.

Classification through this format uses the prompt rung — the endpoint is not
asked to compile a schema.

## Translation services

These speak their own protocols and take no model, so naming one is an error
rather than a silent no-op.

| `--api_format` | Credential |
|---|---|
| `google` | none |
| `deeplfree` | none |
| `tencent` | none |
| `customapi` | none; the endpoint URL goes in `--api_base` |
| `caiyun` | required |
| `deepl` | required (RapidAPI DeepL Translator) |

They translate text and nothing else: no context window and no structured
output. They cannot classify an EPUB for plan mode themselves; with
`--classify-model` naming a model that can, an EPUB gets plan mode on these
engines too. `--source_lang` reaches `customapi`
(it goes into the request body); the other engines detect the source
themselves. (On the LLM routes the flag reaches the prompt — see
Languages below.)

## Languages

`--language LANGUAGE` sets the target language and defaults to `zh-hans`.
It takes a tag (`zh-hant`), a name (`"Traditional Chinese"`), or both at
once — `--language "zh-hant:Traditional Chinese"` — for a language the
built-in tables miss. The tag half is mechanical: stamped on the inserted
markup and `dc:language`, recorded in translation metadata, and it names the
structured-output field. The name half is what the model is asked for in
the prompt. A bare tag or name resolves through the tables as before; a
value matching no tag still runs, prints one `Note:` at startup, and
stamps nothing. The full table: [Language tags and names](languages.md).

```sh
bbook_maker --help
bbook_maker --book_name book.epub --api_format google --language ja
```

`--source_lang` states the source language rather than detecting it; stated,
it reaches every LLM route's prompt and the request body on
`qwen`/`customapi`. The default `auto` states nothing. Not every endpoint
supports every language the parser accepts.

---

### En Prompt (`docs/en/prompt.md`)

# Tweak the prompt

To tweak the prompt, use the `--prompt` parameter. The placeholders the `user` template may use are `{text}` (required), `{language}` and `{crlf}` (a newline, for the shapes that cannot carry one). Anything else in braces is refused before the run starts; write `{{` and `}}` for a literal brace. 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.json`

        # prompt_template.json
        {
            "system": "You are a professional book translator. Translate the given paragraphs into {language} and be accurate, faithful, and fluent. Return translated {language} text only.",
            "user": "Translate the following into {language}. Text:{crlf}{crlf}{text}",
            "style": ""
        }

- A third key, `style`, is a standing instruction about how to write. It is said once where a window starts — with the system message on the API routes, with the thread instructions on codex — not repeated on every request. The shipped [`prompt_template.json`](https://github.com/yihong0618/bilingual_book_maker/blob/main/prompt_template.json) carries all three keys with `style` left empty: write your own voice in, or leave it blank.

- `--prompt` reaches every LLM route, srt books included: there its sections sit on top of the subtitle loader's own prompt, section by section, and replacing the `user` template means saying yourself that the block number and timeline must come back unchanged.

You can also set the `user` and `system` role prompt by setting environment variables: `BBM_CHATGPTAPI_USER_MSG_TEMPLATE` and `BBM_CHATGPTAPI_SYS_MSG` (gemini reads `BBM_GEMINIAPI_USER_MSG_TEMPLATE` and `BBM_GEMINIAPI_SYS_MSG`). `--prompt` outranks all of them. `OPENAI_API_SYS_MSG` is still read as a fallback but is deprecated; `--prompt`'s own system message wins over it.

- A `.md` file is read as the [PromptDown](https://github.com/btfranklin/promptdown) **block** form. The format is theirs; the reader is this repo's own, so no extra package is installed. `--prompt prompt_md.prompt.md`

        # Translation Prompt

        ## System Message

        You are a professional translator who specializes in accurate translations.

        ## Conversation

        **User:**

        Please translate the following text into {language}:

        {text}

  Three sections are read: `## System Message` (`## Developer Message` is
  accepted as another name for it), an optional `## Style`, and
  `## Conversation`, whose first `**User:**` turn is the `user` template. A
  conversation written as a `| Role | Content |` table is refused — the
  template's newlines cannot survive a table cell.

## 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.json
# or
python3 make_book.py --book_name test_books/animal_farm.epub --prompt "Please translate \`{text}\` to {language}"
```

---

### En Providers (`docs/en/providers.md`)

# Provider file and extra models

A run translates through one endpoint. Two kinds of step can use a second one:

- **Classification.** Plan mode asks a model which kinds of block in an EPUB are worth translating. By default that is the translating model. You can name another with `--classify-model`.
- **Steps that look at a page image.** Today there is one: on the [PDF route](features/pdf-to-epub.md), a vision model can correct the roles docling gives the regions of a page. It runs only when you name a model with `--img-model`.

You can name both on the command line or in a provider entry. This page covers the provider file, the two extra models, and which key goes where.

## The provider file

`--provider NAME` takes the route from a JSON file instead of flags. The run reads three files. A later one wins on a shared name:

1. `bbm_providers.example.json`, shipped next to `make_book.py`. The run uses it only for a name that is in neither of the other two files, and it says so every time, with the address and the key variable it used. Its `FILL-ME` entries are templates and are never used.
2. `~/.bbm/providers.json`.
3. `bbm_providers.json` in the directory you run from.

```json
{
  "providers": {
    "siliconflow": {
      "api_style": "openai",
      "base_url": "https://api.siliconflow.cn/v1",
      "default_models": ["Qwen/Qwen2.5-72B-Instruct"],
      "env_key": "BBM_SILICONFLOW_API_KEY"
    }
  }
}
```

```bash
bbook_maker \
  --book_name my_book.epub \
  --provider siliconflow \
  --use_context session
```

Flags you pass yourself win over the entry.

### Fields

| field | required | meaning |
|---|---|---|
| `api_style` | yes | `openai`, `anthropic`, `gemini`, `qwen`, `groq`, `xai` or `litellm` |
| `base_url` | no | the address; defaults to the style's own |
| `default_models` | no | models to use; required if you do not pass `--model` |
| `env_key` | no | the environment variable holding the key; required if you do not pass `--key` |
| `prices` | no | per million tokens, per model: `{"<model>": {"input": …, "output": …, "cached_input": …}}`. With a price for every model in the run, the progress bar shows money spent (`spent=$0.012`) instead of token counts |
| `currency` | no | default `USD`; `EUR`, `GBP`, `CNY`, `JPY` print with their symbol |
| `img_model` | no | the vision model for page-image steps. Stands in for `--img-model` |
| `img_base_url` | no | where `img_model` is served, when it is not this entry's own address. OpenAI-compatible only |
| `img_env_key` | no | the variable holding the key for `img_model`'s address |
| `classify_model` | no | the model for classification. Stands in for `--classify-model` |
| `classify_base_url` | no | where `classify_model` is served, when it is not this entry's own address. OpenAI-compatible, or a [Jev-compatible](#jev-and-jev-compatible-classifiers) classifier's URL |
| `classify_env_key` | no | the variable holding the key for `classify_model`'s address |

The spent amount is an estimate from the usage each request reports; the vendor's bill is the number that counts.

### The shipped `openai` entry turns the image step on

The example file's `openai` entry sets `"img_model": "gpt-6-luna"`. So `--provider openai` on a PDF with `--to-epub` runs the region-role pass on every page by default. That also holds when you have no `bbm_providers.json`, because the run then falls back to the example. The pass costs about 3,000 prompt tokens per page: 43,443 prompt and 4,319 completion tokens for 12 pages in the study behind it ([Region roles with an image model](evaluation/pdf-structure-llm-roles.md)). To run without it, pass `--img-model none`, or copy the file and delete the line.

The `openai-jev` entry is the same `openai` entry plus `"classify_model": "jev"` with `JEV_API_KEY`, so `--provider openai-jev` translates with gpt-6-luna and classifies an EPUB's plan with [Jev](#jev-and-jev-compatible-classifiers). The `jev` entry is Jev on its own: it classifies and never translates, so `--provider jev` is refused with a hint to use `--classify-model`. No other entry names an image model or a classify model.

## The two extra models

### Where each model comes from

| step | first | then | last |
|---|---|---|---|
| image (`--img-model`) | the flag | the entry's `img_model` | **off** |
| classification (`--classify-model`) | the flag | the entry's `classify_model` | the run's own model |

The image step never falls back to the translating model. It runs only when a model is named for it, by the flag or by the entry, so no default needs a model that reads images. `--img-model none` turns an entry's image model off for one run.

`--plan-classify-model` is the old name of `--classify-model`. It still works and is not in `--help`. If you type both, `--classify-model` wins.

### Where each model is asked

- **Without a base URL**, the model is asked at the run's own endpoint, with the run's format and key.
    - An image model needs that endpoint to be OpenAI-shaped. On any other format the run stops before it starts: `--img-model needs an OpenAI-compatible endpoint; … resolves to the … format.`
    - A classify model works on any LLM route that can hold a conversation, the anthropic and codex routes included.
    - On a [translation-service](machine-args.md) run there is no model to share an endpoint with. A classify model named without a base is then asked at the host its id implies: `--classify-model gpt-6-luna` goes to OpenAI.
- **With `--img-base-url` or `--classify-base-url`**, the model is asked there. The address must speak the OpenAI shape, or, for a classifier, the [Jev protocol](#jev-and-jev-compatible-classifiers); anything else is refused before anything is paid for.
- A base URL without its model stops the run: `--img-base-url names where --img-model is served, and no --img-model was given.` (and the same for `--classify-base-url`).

A named classify model always gets its own client, even on the run's address, so its tokens are counted apart from the translation's.

Before the image step runs, the run checks once that the model can read a picture. If it cannot, the run says `… did not read the probe image (…); image steps are skipped this run.` and goes on without the step.

### Which key goes where

A key is bound to an address. It is never sent to a host it was not meant for. For the image model and the classify model, the key is found in this order:

1. `--img-key` or `--classify-key`.
2. The run's key, but only when the model is asked at the run's own address. The run's address is the one after any `--api_base`.
3. The entry's `img_env_key` or `classify_env_key`, but only at the address that entry names for the model: its `img_base_url` or `classify_base_url`, else the entry's `base_url`. An `--api_base` that moves the run elsewhere does not carry the entry's key with it.
4. The variable the address's format reads, as for the run (see [Keys](llm-args.md#keys)).

When none of these holds a key, the run stops and names the flag to pass.

`--extra_body` and `--extra_headers` reach the image or classify model only when it is asked at the run's own address. Headers often carry a gateway's credential, so they never travel to another host.

### Examples

Translate on a local model, and let a hosted model classify the plan and read page images:

```json
{
  "providers": {
    "local-with-helpers": {
      "api_style": "openai",
      "base_url": "http://localhost:11434/v1",
      "default_models": ["qwen3:8b"],
      "classify_model": "gpt-6-luna",
      "classify_base_url": "https://api.openai.com/v1",
      "classify_env_key": "OPENAI_API_KEY",
      "img_model": "gpt-6-luna",
      "img_base_url": "https://api.openai.com/v1",
      "img_env_key": "OPENAI_API_KEY"
    }
  }
}
```

The same with flags:

```bash
bbook_maker \
  --book_name my_book.epub \
  --api_base http://localhost:11434/v1 \
  --model qwen3:8b \
  --classify-model gpt-6-luna \
  --classify-base-url https://api.openai.com/v1 \
  --classify-key "$OPENAI_API_KEY" \
  --use_context session
```

A local server needs no key, so the run's key is empty here; the classifier's key must be named.

## Jev and Jev-compatible classifiers

Jev is TypeSafe's classifier: a model built to answer typed questions, not to write. Plan mode's question for each kind of block, translate it or keep it, is that kind of question, and Jev answers a page of them in one cheap round trip. It translates nothing, so it can only be the classify model. A gateway in front of TypeSafe (Vercel's, or Cloudflare AI Gateway with TypeSafe as a custom provider) speaks the same protocol.

### The commands

| classifier | flags | key |
|---|---|---|
| TypeSafe's Jev | `--classify-model jev` | `JEV_API_KEY` or `TYPESAFE_API_KEY`, sent only to `api.typesafe.ai` |
| Jev through a gateway | `--classify-model typesafe-ai/jev --classify-base-url https://ai-gateway.vercel.sh/typesafe --classify-key "$GATEWAY_KEY"` | named with `--classify-key` |
| Jev through Cloudflare AI Gateway | `--classify-model jev --classify-base-url https://gateway.ai.cloudflare.com/v1/$ACCOUNT_ID/$GATEWAY_ID/custom-typesafe --classify-key "$JEV_API_KEY"` | named with `--classify-key`; `CF_AIG_TOKEN` for an authenticated gateway, which may store the key instead |

For example, translating with gpt-6-luna and classifying with Jev:

```bash
bbook_maker \
  --book_name my_book.epub \
  --model gpt-6-luna \
  --classify-model jev
```

### The rules

- **A key is read from the environment only for its own host.** `JEV_API_KEY` and `TYPESAFE_API_KEY` go only to a typesafe.ai address. Anywhere else, a gateway included, name the key with `--classify-key` or the entry's `classify_env_key`.
- **A Cloudflare gateway's token is read only for Cloudflare.** `CF_AIG_TOKEN` is sent as `cf-aig-authorization` only to gateway.ai.cloudflare.com. With the token set and no key named, no key is sent: the gateway supplies the one it stores. On the gateway, TypeSafe is a [custom provider](https://developers.cloudflare.com/ai-gateway/configuration/custom-providers/) with base URL `https://api.typesafe.ai`.
- **A base URL that already ends in `/systemone` is used as it is.** Any other Jev base gets `/systemone` added after a `/v1`.
- `jev` alone asks for TypeSafe's current model, `jev-latest`.

### The gate: a doubtful skip is translated

Jev returns a probability with each answer. A `skip` below the gate is recorded as `translate`, so no content is lost to a skip Jev was unsure of. A `translate` is taken at any probability. The gate is 0.95 on the probability of the chosen answer, measured over 662 plan signatures from 45 EPUBs against gpt-5.6-luna. At that value about nine of ten of Jev's skips become `translate`, and what Jev still skips is apparatus: copyright lines, line numbers, note marks, index locators. So on that corpus Jev saves little over translating everything; see [Jev as the plan classifier](evaluation/plan-classifier-jev.md). The plan file shows each fallback on its row: `unnamed (jev verdict skip at confidence 0.61, below the gate: translate)`. `--classify-min-confidence P` (0 to 1) moves the gate for a run; `BBM_JEV_MIN_CONFIDENCE` does the same without the flag, and the flag wins. A lower value keeps more of Jev's skips: on the measured corpus those below 0.9 agreed with the reference 7–55% of the time, so the risk is yours.

### In a provider entry

If you want OpenAI to translate and Jev to classify, the shipped `openai-jev` entry already does it: `--provider openai-jev`. For any other pairing, write your own entry.

`classify_model`, `classify_base_url` and `classify_env_key` name a Jev classifier the same way the flags do:

```json
{
  "providers": {
    "openai-with-jev": {
      "api_style": "openai",
      "default_models": ["gpt-6-luna"],
      "env_key": "OPENAI_API_KEY",
      "classify_model": "typesafe-ai/jev",
      "classify_base_url": "https://ai-gateway.vercel.sh/typesafe",
      "classify_env_key": "JEV_API_KEY"
    }
  }
}
```

An entry that names `JEV_API_KEY` for its own gateway address has named the key for that address, and it is honored. Without such an entry, the Jev variables are never sent to a gateway.

Each Jev request sends the page's candidate lines once, with one short question per signature. See [Jev as the plan classifier](evaluation/plan-classifier-jev.md).

## What the run prints

A `--plan-dry-run` shows where each model would be asked, without a key:

```text
Classifier: gpt-6-luna at the openai endpoint's default host (cli)
Image model: off
```

The part in brackets is where the choice came from: `cli`, `provider` or `run`. Under `--plan-classify all` or `agent` the line reads `Classifier: none (--plan-classify agent asks nothing)`.

At the end of the run, a model on a client of its own prints its own usage line under the translation's:

```text
Classifier (gpt-6-luna at the endpoint's default host): tokens: in 7.6k, out 1.8k, cached 0 (3 requests)
```

The image model's line reads `Image model (<model> at <address>): …` and is printed after the extraction.

These warnings mean a flag does nothing this run:

- **`--img-model, --img-base-url and --img-key choose the vision model for the steps that look at a page image, and only the PDF route (--to-epub on a PDF) has one; …`** You named an image model on a book that is not a PDF on the `--to-epub` route.
- **`--classify-model names a classifier, and --plan-classify all translates the whole partition without classifying anything; it is ignored this run.`** The same for `--plan-classify agent`: agent mode leaves every row to your agent and asks no model.
- **`Nothing on this route classifies yet, so --classify-model is ignored on a … book.`** Only an EPUB has a classification step today.

---

### En Session Grouping Eval (`docs/en/session_grouping_eval.md`)

This report was split into [Units and tokens per request](evaluation/grouping-batch-size.md) and [Session compact budget](evaluation/session-compact-budget.md).

---

### Zh Agent Skill (`docs/zh/agent-skill.md`)

# 用智能体翻译

你有一本书，想把它翻译好。最省事的办法是交给一个带有本仓库 `bbm-plan` 技能的编程智能体。智能体会问清你手头有什么、选好参数，给你看命令和费用，安静地运行工具，最后交回双语书。你不需要学任何一个参数。

## 你需要准备

- **本仓库的源码。**技能在仓库根目录运行 `make_book.py`，所以只 `pip install bbook_maker` 是不够的：

    ```bash
    git clone https://github.com/yihong0618/bilingual_book_maker.git
    cd bilingual_book_maker
    pip install -r requirements.txt   # pip install ".[pdf]" if you have PDFs
    ```

- **你要用的模型的凭据**：放在 `.env` 或环境变量里的 API key、跑在你自己机器上的模型，或者经由 codex 路线使用的 ChatGPT 套餐。智能体会问你用哪一种；每种各需要什么，见[用大模型翻译](llm-args.md)。
- **一个能读取技能的编程智能体**：Codex、Claude Code，或者 `skills` CLI 能安装到的任何智能体。

## Codex

Codex 会从仓库读取 `.agents/skills/`，所以什么都不用装。在仓库目录里启动 `codex`，描述任务，或者直接点名技能：

```text
$bbm-plan translate ~/Books/my_book.epub to Japanese
```

## Claude Code 和其他智能体

先安装一次技能。这条命令会找出你机器上的智能体，并问你装到哪一个：

```bash
npx skills add yihong0618/bilingual_book_maker --skill bbm-plan
```

然后在仓库目录里启动智能体，描述任务。在 Claude Code 里，技能也可以直接用名字调用：

```text
/bbm-plan translate ~/Books/my_book.epub to Japanese
```

## 智能体会做什么

1. **问清你手头有什么。**路线（key、本地模型、ChatGPT 套餐）、目标语言、你常用的提示词文件（如果有），以及你想改动的默认设置。默认输出双语：原文每段后面紧跟译文。如果只想要译文，就说“只要中文”。
2. **探测端点**：在花任何钱之前，先发一个花费不到一美分的请求。
3. **说明命令**：每个参数一行并附理由，告诉你会看到的检查点，以及花的是谁的额度。然后等你批准。
4. **按文件类型走对应流程。**
    - EPUB 走[计划模式](features/plan-mode.md)：工具先把书切分好，智能体根据真实样本自己判断哪些是页眉、页码或脚注标记，并在完整运行之前把计划给你看。
    - PDF 转成[双语 EPUB](features/pdf-to-epub.md)：先翻两页，智能体读过产出之后，再翻整个文件。
    - TXT、SRT 和 Markdown 各只需一条命令。
5. **安静地运行**，日志写到 `run.log`；崩溃后或开新会话时，从停下的地方继续。它需要的一切都在磁盘上，没有任何东西只存在于对话中。
6. **交付**：把 `_bilingual` 文件放在你的书旁边。

## 阅读技能内容

技能由路由文件 `.agents/skills/bbm-plan/SKILL.md` 加上每个流程一份参考文件组成（路线设置、EPUB 计划模式、PDF 路线、纯文本格式、提示词文件、提供方），只在运行到那一步时才加载。可以在 GitHub 上阅读：[`.agents/skills/bbm-plan`](https://github.com/yihong0618/bilingual_book_maker/tree/main/.agents/skills/bbm-plan)。凡是它没有写明的内容，它指向的都是本 wiki 里的同一批页面。

---

### Zh Book Source (`docs/zh/book_source.md`)

# 各类文件对应的页面

文件的扩展名决定它怎么被读取。找到你的那一行：

| 如果你的文件是…… | 先运行 | 再阅读 |
|---|---|---|
| EPUB | `bbook_maker --book_name book.epub --use_context session` | [EPUB](formats/epub.md)、[EPUB 推荐设置](features/recommended-epub.md) |
| PDF | `python make_book.py --book_name book.pdf --to-epub --pages 1-2 --test` | [PDF 转双语 EPUB](features/pdf-to-epub.md)、[PDF 推荐设置](features/recommended-pdf.md) |
| 纯文本文件 | `bbook_maker --book_name book.txt --batch_size 20` | [TXT](formats/txt.md) |
| 字幕 | `bbook_maker --book_name talk.srt --accumulated_num 400` | [SRT](formats/srt.md) |
| Markdown 文档 | `bbook_maker --book_name doc.md --prompt prompt_md.json --use_context session` | [Markdown](formats/md.md) |

PromptDown 格式的 `.md` 文件是提示词，不是书：它交给 `--prompt`，而 Markdown 格式的书交给 `--book_name`。

## 不用计划翻译 EPUB

如果只想翻译指定的标签，就关掉计划并列出这些标签：

```bash
bbook_maker --book_name test_books/animal_farm.epub --plan-classify none --translate-tags div,p
```

如果一本书有不在任何标签里的文字，`--allow_navigable_strings` 会把它们也加进来。计划本来就同时覆盖这两种情况，所以这些参数是给想精确控制的书用的。无论哪种方式，结构规范的 EPUB 效果都更好。

---

### Zh Cmd (`docs/zh/cmd.md`)

# 命令行参数

运行时以 `book_maker/cli.py` 和 `python3 make_book.py --help` 为准。
下面的清单已与 `argparse` 注册的每个长参数逐一核对过；清单之后的各节为部分工作流提供补充说明。

## 完整参数清单

### 输入、范围与输出

| 参数 | 用途 |
|---|---|
| `--book_name PATH` | 输入的 EPUB、TXT、Markdown、SRT 或 PDF 路径（必填）。 |
| `--language LANGUAGE` | 目标语言：标签（`zh-hant`）、语言名（`"Traditional Chinese"`），或在内置表里查不到该语言时用 `TAG:NAME` 同时指定两者——标签用于输出标记和字段名，语言名用于提示词。默认 `zh-hans`；列表见 `docs/en/languages.md`。 |
| `--source_lang LANGUAGE` | 源语言。明确指定时，它会写进每条大模型路线的提示词，在 `qwen`/`customapi` 上还会写进请求体；默认 `auto`（什么都不声明）。 |
| `--single_translate` | 只输出译文，而不是双语文本。 |
| `--no_disclosure` | 不把 epub 标记为 AI 翻译：去掉书籍简介下方那一行署名（“Translated by \<model\>, \<year\>.”）。同时关闭 `--translation-metadata`。 |
| `--translation-metadata` | 在书里写入一个小小的 `bbm_translation_metadata.json`：模型、日期，以及 `--glossary` 文件的 sha256（文件文本一并嵌入；运行自己学到的术语不会写入）。别的都没有——没有命令行、端点、语言或路径，也没有包的元数据：记录的是可能影响译文的东西，而不是谁运行了它。格式转换会保留清单中的文件，所以这份记录能经受住转换。计划模式和会话模式的运行会自行写入它；这个参数让普通的标签模式运行也写入。`--no_disclosure` 同样会关闭它。 |
| `--translate-tags TAGS` | 逗号分隔的 EPUB 标签；默认 `p`，计划模式下忽略。 |
| `--exclude-translate-tags TAGS` | 要排除的 EPUB 祖先标签；默认 `sup,code`；传 `""` 清空。 |
| `--allow_navigable_strings` | 把原本不在标签里的 EPUB 字符串也包括进来；计划模式下多余。 |
| `--only_filelist FILES` | 只包括这些逗号分隔的 EPUB 内部文件。 |
| `--exclude_filelist FILES` | 排除这些逗号分隔的 EPUB 内部文件。 |
| `--translation_style CSS` | 应用于 EPUB 译文条目的 CSS。 |
| `--translation_color COLOR` | 只设颜色的简写；`--translation_style` 优先。 |
| `--pdf_layout MODE` | 额外生成的 PDF 输出：`none`、`top-bottom`、`side-by-side` 或 `all`。 |
| `--to-epub` | 仅限 PDF：用 docling 读取 PDF，翻译得到的 Markdown，在其旁边写出 `<name>_bilingual.epub`；插图保留为图片；包留在 `<name>_book/` 里，便于编辑和续跑。需要 `pdf` 扩展和 PATH 上的 Pandoc 3.1.12+；不需要 Java。见 [installation-pdf.md](installation-pdf.md)。 |
| `--pdf-ocr` | 仅限 PDF，配合 `--to-epub`：读取没有文字层的页面，不加这个参数时这些页面会被拒绝。默认关闭；OCR 要多花好几倍的时间，而且对原生数字 PDF 没有任何改变。无论开不开，版面、标题和表格都会被检测。 |
| `--ocr-replace-layer` | 仅限 PDF，配合 `--to-epub --pdf-ocr`：对每一页做 OCR，并替换 PDF 自带的文字层，而不是保留它。默认关闭；测得它比一个完好的文字层差，是给有错的文字层用的。读出为空的页面保持为空，并会被点名。 |
| `--no-formula-images` | 仅限 PDF，配合 `--to-epub`：让行间公式保留为 `` 占位符，而不是把每个公式从页面上裁剪成图片。解析器能找到公式，但从不读取它们，所以这些图片是数学内容能进入书中的唯一途径；它们不花模型费用，也不花可测量的时间。段落里的行内数学不算公式区域，无论如何都不在覆盖范围内。 |
| `--pdf-image-dpi N` | PDF，`--to-epub`：插图清晰度，以 PDF 自身页面尺寸下的 DPI 计；默认 200，标注很小时用 300；换一个值重新运行只会重绘插图 |
| `--img-model MODEL` | 仅限 PDF，配合 `--to-epub`：一个视觉模型，在导出前根据页面图像纠正版面检测器给出的区域角色（正文、标题、书名、图题、脚注、代码）。除非在这里或在提供方条目的 `img_model` 中指定，否则关闭；`none` 会关掉它。绝不会回退为本次运行自己的模型。每页约 3k 提示词 token。 |
| `--img-base-url URL` | `--img-model` 的端点，当它不是本次运行的端点时使用（仅限 OpenAI 兼容）。 |
| `--img-key KEY` | `--img-base-url` 使用的 key。默认：端点是本次运行自己的端点时，用本次运行的 key；否则，端点是提供方条目自己的端点时，用该条目的 `img_env_key`；否则，用该端点格式从环境中读取的 key。key 绝不会发往不是为它指定的地址。 |
| `--device DEVICE` | 仅限 PDF，配合 `--to-epub`：提取模型在哪里运行——`auto`（默认；检测 CUDA 或 MPS，没有则回退到 CPU）、`cpu`、`cuda`、`mps`、`xpu`。CPU 完全受支持，输出相同，只是更慢。 |
| `--ocr-lang LANGS` | 仅限 PDF，配合 `--to-epub --pdf-ocr`：OCR 引擎在没有文字层的页面上（加 `--ocr-replace-layer` 时是每一页）识别的语言，逗号分隔，使用引擎自己的代码（rapidocr：`ch`、`en`、`latin`；easyocr：`ch_sim`、`ja`、`ko`；ocrmac：`zh-Hans`、`ja-JP`），或加 `iso:` 前缀的通用 BCP-47 标签（`iso:zh-Hans`、`iso:zh-Hant`、`iso:ja`、`iso:ko`），docling 2.129 会把它映射到实际运行的引擎上。rapidocr 每次运行只识别一种语言，取第一个。引擎是 docling 在当前安装中选中的那个（装了 PDF 扩展时，Mac 上是 ocrmac，默认识别英语、西班牙语、法语和德语；其他系统上是 rapidocr，默认识别中文和英文），运行会打印它用的引擎和语言。其他文字的扫描件需要这个参数；运行遇到没加它的扫描页时会提示。未知的代码在读取任何页面之前就会被拒绝。每个引擎的代码，以及该选哪个引擎：[选择 OCR 引擎](features/pdf-ocr-engines.md)。 |
| `--ocr-engine ENGINE` | 仅限 PDF，配合 `--to-epub --pdf-ocr`：用于没有文字层的页面（加 `--ocr-replace-layer` 时是每一页）的 OCR 引擎。`auto`（默认）按 ocrmac、rapidocr、easyocr 的顺序取第一个已安装的；`pdf` 扩展会安装带 onnxruntime 的 `rapidocr`（模型已包含），在 macOS 上还会安装 `ocrmac`（Apple 的 Vision 框架）；两者都不下载任何东西，所以 `auto` 在 Mac 上用 ocrmac 识别，在其他系统上用 rapidocr；`easyocr` 首次使用时下载模型（`pip install easyocr`）；`tesseract` 使用 PATH 上的 tesseract 程序及其语言数据。未安装的引擎，或在 macOS 之外指定 `ocrmac`，都会在读取任何页面之前被拒绝。语言代码因引擎而异（`--ocr-lang`）；运行会说明它用的引擎。该选哪个，有测量数据：[选择 OCR 引擎](features/pdf-ocr-engines.md)。 |
| `--pages PAGES` | 仅限 PDF，配合 `--to-epub`：要读取的页，从 1 开始编号（`12-30`、`1,3,5-7`）；其余页不处理。所选页码会写进包名和书名（`<name>_pages-12-30_…`），所以翻译一章永远不会覆盖整本书。如果所选范围从某节中间开始，`source.md` 里会在它的第一段正文上方加一个 `Page N` 标题。 |
| `--retranslate OUT FILE START END` | 在已有的输出中重新翻译一段 EPUB 范围。仅限 EPUB——其他格式会拒绝。 |

### EPUB 计划模式

| 参数 | 用途 |
|---|---|
| `--plan-dry-run` | 构建并打印 EPUB 计划，写出所有 `action` 仍为 `null` 的 `<book>_plan.json`，然后退出。不需要凭据。 |
| `--plan-classify {auto,none,all,model,agent}` | 不用计划、翻译整个分区、由模型筛选，或由编程智能体筛选。默认 `auto`：在任何能作答的 epub 端点上由模型筛选——经验证支持严格 JSON schema 时通过结构化输出，其他情况（包括 codex）通过普通对话（精确回答 `skip`/`translate`，其他回答一律翻译）；只有在根本无法对话的地方才用标签模式。 |
| `--classify-model MODEL` | 每个分类步骤使用的模型（旧名 `--plan-classify-model`），或一个 Jev 兼容的分类器（默认是 TypeSafe 的 Jev；网关需给出其 URL）；在 epub 上意味着 model 模式。其端点经验证支持 JSON schema 时通过 schema 提问，否则通过普通对话。默认：提供方条目的 `classify_model`，其次是本次运行的模型。 |
| `--classify-base-url URL` | `--classify-model` 的端点，当它不是本次运行的端点时使用：一个 OpenAI 兼容端点，或一个 Jev 兼容的分类器的 URL（以 `/systemone` 结尾的路径原样使用）。 |
| `--classify-key KEY` | `--classify-base-url` 使用的 key；默认规则与 `--img-key` 相同。`JEV_API_KEY`/`TYPESAFE_API_KEY` 只在 typesafe.ai 地址上被自动读取，Cloudflare AI Gateway 的 `CF_AIG_TOKEN` 只在 gateway.ai.cloudflare.com 上读取；提供方条目仍然可以把某个变量绑定到它自己的地址。 |
| `--classify-min-confidence P` | Jev 兼容的分类器的置信度闸门，0 到 1：概率低于它的 `skip` 改为 `translate`；`translate` 从不受闸门限制。默认 `0.95`，经过测量；低于 `0.5` 时闸门不起作用。`BBM_JEV_MIN_CONFIDENCE` 不用参数也能设置它。 |
| `--plan-min-coverage FRACTION` | 计划选中的文字比例低于此值时失败；默认 `0.5`，必须在 0 到 1 之间（`0` 关闭这道防线，高于 `0.9` 的值多半会中止——两种情况都会警告）。 |
| `--poetry-group-size N` | 已弃用——现在通用分组和会话交接会让短行和相邻内容放在一起，单元数上限是 `--max-batch-units`。仍然可用（默认 `8`，最小 `1`），但会警告。 |

### 翻译与执行

| 参数 | 用途 |
|---|---|
| `--test` | 只翻译一小段预览样本。 |
| `--test_num N` | 测试的单元数；默认 `10`。 |
| `--resume` | 从加载器保存的检查点继续。EPUB 检查点会记录本次运行的语言、提示词和模型；不一致时拒绝续跑（旧检查点只警告一次，然后继续）。与 `--parallel-workers` 以及大于 1 的 `--accumulated_num` 同用会被拒绝，因为那两种情况从不写检查点。 |
| `--prompt VALUE_OR_FILE` | 提示词配置：`user`（必须包含 `{text}`）、`system` 和 `style`。`style` 是常驻指令，只在窗口开始时说一次（API 路线上放在 system 消息里，codex 上放在线程指令里），从不在每个请求里重复；用户写的 style 会替换交接报告里模型自己的文风说明。某个部分在某条路线上没有原生位置时（`codex` 格式上的 `system`），会附加到 user 消息里，而不是被丢弃；带 `--prompt` 的运行会打印它采用了哪些部分、放在哪里。示例：`prompt_template.json`（随附的 `style` 为空）。 |
| `--temperature FLOAT` | 采样温度，仅用于接受该参数的格式；默认 `1.0`。anthropic 格式总会发送它。openai 格式在它等于 API 默认值时，以及模型拒绝显式设置时（gpt-5.x、o 系列），不发送它，由 API 默认值生效。codex 格式没有这项设置，会忽略它。 |
| `--use_context [window\|session]` | 把前面的段落作为上下文发送。不带值或 `window`：重发最近几对原文与译文（一贯的行为）。`session`：一份只追加的历史，按端点的提示缓存价重读。 |
| `--context_paragraph_limit N` | 仅窗口模式：上下文历史的上限。解析器默认值 `0` 表示采用翻译器的默认值（ChatGPT 为 3 段），而不是没有历史。 |
| `--context-compact-at N` | 滚动历史的估算 token 预算。会话模式下，历史达到这个大小时会被压缩成交接报告；最小 `1500`。不设时，每个会话运行——无论是否分组，包括 `codex` 格式——都在 `8192` 处压缩，并在开始时打印出来。这个默认值是为连贯性（窗口接缝最少）而选的，不是为价格：会话模式下更低的值更便宜，因为每个请求都会重读携带的历史，所以如果你更在意会话成本而不是接缝，就调低它；如果模型的输入上限更小，就设成那个上限。超过约 `16000` 后成本会急剧上升。测量数据见[会话压缩预算](evaluation/session-compact-budget.md)。它也限定计划分类器在那些通过普通对话分类的端点上的对话长度（在那里重新开始，不写交接报告），无论有没有 `--use_context`。显式给出的值总是优先。 |
| `--no-context-compact` | 仅会话模式：跳过交接报告。窗口仍在达到预算时滚动，但下一个窗口从空白开始。 |
| `--glossary FILE` / `--terminology FILE` | 一个由 `term → translation` 行组成的文件（每行一条；`#` 之后是备注或注释），本次运行必须照此翻译。同一个参数的两个名字。只有出现在某个请求里的术语才会随它发送。文件不存在时，运行在解析参数阶段就会中止。由 openai 系和 codex 系路线在 EPUB、Markdown 和 PDF 书上读取；其他路线会警告并忽略它。 |
| `--glossary-auto on\|off` | 会话运行是否同时保留它自己的交接报告里确立的译名。除非你要求，否则关闭。它需要一个可供学习的会话（`--use_context session`，或 `codex` 格式的那一个线程），以及一个会用名字而不是大段文字作答的模型；`off` 时压缩那一轮只要求写摘要。学到的术语只留在本次运行和 `<book>_handoff.md` 里，不会出现在别处。 |
| `--accumulated_num N` | EPUB 的 token/字符累积量，以及 SRT 字幕块按字符合并的批量（SRT 上限为 512）。在 EPUB 计划模式下，它是每个请求的 token 预算：任意长度的相邻单元在 `N` 个 token 之内合成一次请求（每个请求最多 `--max-batch-units` 个单元；端点经验证支持 JSON 模式但不支持严格 schema 时为其一半）。不设时，每次计划运行都会根据本次运行自己的提示词开销推算默认值——用自带提示词时为 `1200`，自定义 `--prompt` 较长时最多 `1600`——在没有严格 schema 结论的端点上，每个请求的预算减半，但绝不低于 `800` 的下限（所以在那里，无论提示词开销多大，每个请求的预算都是 `800`）；会话运行（包括 `codex`）保持未减半的值。这些是选定的安全余量，低于所有测量中未出现错误的范围；见[每次请求的单元数与 token 数](evaluation/grouping-batch-size.md)。运行会说明这个数字以及路线类别。传 `1` 关闭合并。最小 `1`。 |
| `--max-batch-units N` | 仅 EPUB 计划模式：`--accumulated_num` 的 token 预算最多能放进一个请求的单元数。默认 `16`，即首次出现内容错误的水平（每个请求 64 个单元）的四分之一，作为安全余量。端点经验证支持 JSON 模式但不支持严格 schema 时，携带其一半（`8`），回复数错的情况实际就出在这类端点上。如果你信任自己的模型、想减少请求，就调高它；如果运行一直打印错位恢复的提示，就调低它。 |
| `--batch_size N` | TXT、Markdown 和 PDF（纯文本路线）加载器每个请求发送的行数或段落数。默认 `10`。 |
| `--block_size N` | 把段落合并成用分隔符方式翻译的块。 |
| `--sentence_mode` | 把 EPUB 段落逐句翻译；与计划模式不兼容。 |
| `--parallel-workers N` | 并行处理 EPUB 章节或 Markdown 批次/分节；默认 `1`。与 `--use_context session` 同用（只有一份历史）以及在 `codex` 格式上（只有一个线程）会被拒绝。 |
| `--batch` | 提交一个 ChatGPT Batch API 任务。在 EPUB 上会被拒绝（那里走不到队列路径：运行会按全价实时翻译，并提交一个空任务，而不是写出书），在不支持 Batch API 的路线上也会被拒绝。TXT、SRT 和 Markdown 加载器也没有实现它（运行会实时翻译），所以目前没有任何格式用到它。 |
| `--batch-use` | 使用之前提交的批量任务。在 EPUB 上会被拒绝，与 `--batch` 相同。 |
| `--no-thinking` | 让模型回答前不要先推理。在 OpenAI 形态的路线上，请求字段根据端点自己的拒绝来协商，并按端点和模型缓存；如果每种写法都被拒绝，运行会警告一次，然后不带该字段继续。在 `anthropic` 上是 `thinking: {"type": "disabled"}`。在 `codex` 上会被拒绝（子进程没有请求体）；在自行构建请求的路线上会被警告为不起作用。`--extra_body` 中设置的字段优先。 |
| `--extra_body JSON` | 附加到每个请求体上的字段，适用于构建请求体的路线（`openai`、`groq`、`xai`、`litellm`、`--model orcarouter`、`anthropic`）；其他路线会忽略它并说明。它也会进入能力探测和 JSON 各级请求，所以端点是按本次运行实际发出的请求来评定的。它合并在具名参数之上，所以这里的字段优先于对应的参数。 |
| `--extra_headers JSON` | 附加到每个请求上的 HTTP 头，适用路线同上。它设在客户端上，所以能力探测、模型检查和模型列表都会带上它们。值必须是字符串。 |
| `--quiet` | 不显示 EPUB 进度条和段落回显，报告和错误照常显示。 |
| `--proxy URL` | 为本次运行设置 HTTP/HTTPS 代理环境变量。 |

### 端点与凭据

路线指的是端点，而不是模型名。

| 参数 | 用途 |
|---|---|
| `--model MODEL` | 模型 id，按端点自己的写法（`gpt-5-mini`、`claude-sonnet-4-6`、`openai/gpt-5-mini`）。`openai` 格式下默认 `gpt-6-luna`；`anthropic` 格式必须指定。旧的别名值会被改写，并附一条说明。 |
| `--api_base URL` | 端点地址。默认是该格式的官方地址。粘贴进来的 `…/v1/chat/completions` 或末尾的斜杠会被去掉。 |
| `--key KEY` | API key；用逗号分隔多个 key 可以轮换使用。更推荐用 `BBM_API_KEY` 或该格式自己的变量。 |
| `--api_format FORMAT` | 端点所用的 API：`openai`（默认）、`anthropic`、`gemini`、`qwen`、`groq`、`xai`、`litellm`、`codex`、`google`、`caiyun`、`deepl`、`deeplfree`、`tencent`、`customapi`。根据 `--api_base` 的主机推断，否则根据含 `claude`/`anthropic` 的模型 id 推断——各厂商格式从不推断，所以要写明。 |
| `--api_format gemini` \| `qwen` | Google 和阿里巴巴各自的协议，各有自己的翻译器：Gemini 原生的约束解码和对话历史，以及 Qwen-MT 的语言对请求。默认分别为 `gemini-flash-latest` 和 `qwen-mt-turbo`。 |
| `--api_format groq` \| `xai` \| `litellm` | Groq、xAI 和 LiteLLM 代理（`http://localhost:4000`）上的 OpenAI 形态。每种都自带地址，所以格式加一个 key 就是完整的路线。必须写 `--model`：这些模型目录更新很快，所以不预设任何模型。 |
| `--model codex` | 使用 ChatGPT 套餐的 Codex CLI 侧车，等同于 `--api_format codex`。它运行 `gpt-6-luna`；`--api_format codex --model <id>` 可以指定其他模型（侧车还提供 `gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.5`、`gpt-5.2`）。 |
| `--model orcarouter` | OrcaRouter 网关及其智能路由模型 `orcarouter/auto`。不需要 `--api_base`；如果你传了，以你的为准。key 来自 `BBM_ORCAROUTER_API_KEY`。它不是旧别名：不会改写任何东西。 |
| `--model_list IDS` | 轮换使用的多个模型 id，逗号分隔。单个模型应该写在 `--model` 里；在两个参数里都写模型会报错。与 `--use_context session` 同用会被拒绝：轮换会让每个请求都成为全价的缓存未命中，还会在一段对话里混用多个模型。 |
| `--source_lang LANG` | 源语言。明确指定时，它会作为依据写进每条大模型路线的提示词，在 `qwen`/`customapi` 上还会写进请求本身；默认 `auto`。 |
| `--interval SECONDS` | 请求之间的间隔，默认 `0.01`。只有 `gemini` 路线用它控制节奏。 |
| `--provider NAME` | `bbm_providers.json`（当前目录）或 `~/.bbm/providers.json` 中的一个具名端点；同名时项目文件优先，两处都没有的名字会回退到随附的 `bbm_providers.example.json`，并给出警告，说明它用的地址和 key 变量（不包括其中的 `FILL-ME` 模板）。条目中的 `base_url`、`api_style`（`openai`、`anthropic`、`gemini`、`qwen`、`groq`、`xai` 或 `litellm`）、`default_models` 和 `env_key` 分别代替 `--api_base`、`--api_format`、`--model`/`--model_list` 和 key；`img_model`/`img_base_url`/`img_env_key` 和 `classify_model`/`classify_base_url`/`classify_env_key` 分别代替 `--img-model`/`--img-base-url`/`--img-key` 和 `--classify-model`/`--classify-base-url`/`--classify-key` 参数（见[提供方文件与额外模型](providers.md)）。你自己传入的参数优先。 |

提供 Claude 模型的网关说的是 OpenAI 形态，给了网关的 `--api_base` 时也按这种形态处理。
只有在 Anthropic 自己的主机上，或者没有 `--api_base` 而模型 id 含 `claude` 时，才会推断为 anthropic 格式。
如果向一个不提供 anthropic 形态的网关请求这种形态，它会返回 404，运行随即中止，并指出修正方法是 `--api_format openai`。

key 的查找顺序：`--key`（`--api_key` 是同一个参数），然后是 `BBM_API_KEY`，
再然后是该格式自己的变量：`OPENAI_API_KEY`、`ANTHROPIC_API_KEY`、
`BBM_GOOGLE_GEMINI_KEY`、`BBM_QWEN_API_KEY`、`BBM_GROQ_API_KEY`、
`BBM_XAI_API_KEY`、`BBM_CAIYUN_API_KEY`、`BBM_DEEPL_API_KEY`（每个之后还有该厂商惯用的变量）。
localhost 上的端点不需要 key。

旧的 `--model` 预设名、各厂商的 `--*_key` 参数、`--ollama_model` 和 `--deployment_id` 已经不在解析器里了，
但旧命令行仍然能运行：`book_maker/legacy_cli.py` 会在运行开始前把它们改写成上面的参数，并打印每一处改写。
对照表见[从旧参数迁移](migration.md)。
旧的各厂商 key 变量（`BBM_GROQ_API_KEY`、`BBM_GOOGLE_GEMINI_KEY`……）在原来使用它们的路线上仍然会被读取。

不要把密钥直接写在共享的命令行上。对智能体和 CI 来说，环境变量更安全。
CLI 自己**不会**加载 `.env` 文件：请先导出变量，或者在运行前 source 一个被 git 忽略的本地文件，例如
`set -a; source .env; set +a; bbook_maker ...`。

## 测试翻译
`--test` <br>

如果你还没为服务付费，或者只想试一试，可以用这个参数预览结果。注意会有限制，可能需要一些时间。

```sh
bbook_maker --book_name test_books/Lex_Fridman_episode_322.srt --key ${openai_key} --model gpt-5-mini  --test
```

```sh
bbook_maker --book_name test_books/animal_farm.epub --key ${openai_key} --model gpt-5-mini  --test --language zh-hans
```

`--test_num <TEST_NUM>`<br>

用这个参数设置测试时要翻译多少段。默认是 10。

## 续跑
`--resume` <br>

中断后，用这个参数手动继续处理。

## 重新翻译（仅 epub）
`--retranslate <translated_filepath> <file_name_in_epub> <start_str> <end_str>`<br>

如果 EPUB 中某个文件翻译得不好，可以用它单独重新翻译其中一部分。
argparse 要求四个值都给出。`end_str` 传空值时只重新翻译起始标签；
`file_name_in_epub` 传空值时会自动查找文件名。

- 从 start_str 重新翻译到 end_str 所在的标签：

        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'

- 只重新翻译 `start_str` 所在的标签（第四个值为空）：

        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' ''

- 重新翻译 `start_str` 所在的标签，并自动查找文件名（第二个和第四个值为空）：

        bbook_maker --book_name "test_books/animal_farm.epub" --retranslate 'test_books/animal_farm_bilingual.epub' '' 'in spite of the present book shortage which' ''

**警告：**

**它会从成书中 start_str 所在的标签开始，删到 end_str 所在的下一个标签为止，然后重新翻译。**

**因此，请确保 `end_str` 之后的那个标签是译文内容。`end_str` 为空字符串时，使用 `start_str` 之后的那个标签。两个字符串之间可以有漏译，但结束边界若不是译文就会出问题。**

## 自定义输出样式（仅 epub）
`--translation_style <TRANSLATION_STYLE>`<br>

支持修改 epub 文件的输出样式。

    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>` <br>

用这个参数指定访问互联网的代理服务器。传入类似 `http://127.0.0.1:7890` 的字符串。

## API 地址
`--api_base <API_BASE_URL>`<br>

如果你想更换 api_base，比如使用 Cloudflare Workers，可以用这个参数。<br>

    bbook_maker --book_name 'animal_farm.epub' --key sk-XXXXX --model gpt-5-mini --api_base 'https://xxxxx/v1'
**注意：api 地址应该是 '`https://xxxx/v1`' 这样的形式，必须加引号。**

## Microsoft Azure 端点

Azure 没有专门的参数。把 `--api_base` 指向部署的 OpenAI 兼容 URL，
并在 `--model` 里写上部署名：

    bbook_maker --book_name 'animal_farm.epub' --key XXXXX --api_base 'https://example-endpoint.openai.azure.com/openai/v1' --model 'deployment-name'

## 批量大小（TXT、Markdown、PDF 纯文本路线）
`--batch_size`<br>

用这个参数指定批量翻译的行数。默认是 10。由 TXT、Markdown 和 PDF（纯文本路线）加载器读取；EPUB 用的是 `--accumulated_num`。
```sh
python3 make_book.py --book_name test_books/the_little_prince.txt --test --batch_size 20
```

## 累积 token 数
`--accumulated_num <ACCUMULATED_NUM>`<br>

累积到多少个 token 之后才开始翻译。gpt3.5 把 total_token 限制在 4090。

在 EPUB 计划模式下你很少需要它：运行会自动推算每个请求的预算（用自带提示词时为 1200，在不支持严格 schema 的端点上为 800），并把它打印出来。见上面 `--accumulated_num` 那一行和[每次请求的单元数与 token 数](evaluation/grouping-batch-size.md)。

例如，如果你用 --accumulated_num 1600，openai 可能会输出 2200 个 token，
system 消息和 user 消息里的其他内容可能还要 200 个 token。1600+2200+200=4000，已经接近上限了。

你得自己选一个合适的值，在发送请求之前无法判断是否已经达到上限。

---

### Zh Disclaimer (`docs/zh/disclaimer.md`)

# 免责声明

bilingual_book_maker 帮助用户制作图书及其他文档的多语言版本。

1. **请仅翻译您有权翻译的内容。** 这包括您持有必要权利的作品、许可协议或权利人授权允许您按预期方式使用的作品、依据适用法律已进入公有领域的作品，以及适用法律另行允许的使用情形。持有或购买一本书或一个副本，本身并不授予翻译权。

2. **分别核实翻译和分发的权利依据。** 翻译通常属于改编（演绎作品）。个人使用并不自动免受著作权限制。如果您分享译本，请确认许可协议、授权或适用法律也允许分发输出文件中包含的全部内容，包括原文和图片。同一许可协议或授权可以同时涵盖翻译与分发。使用知识共享（CC）许可的作品时，请核实具体许可及版本，包括其中适用的署名、非商业性使用、相同方式共享和禁止演绎等条件。例如，禁止演绎许可不授权公开分享译本。作品是否进入公有领域因司法辖区而异；现代译本、编辑新增内容、序言及插图可能独立于古旧原文受到保护。

3. **MIT 许可证不为图书内容授予许可。** 本项目的 MIT 许可证覆盖软件及随附文档；它不授予输入图书或其他第三方内容的任何权利，也不会自动适用于生成的文件。输出内容的使用仍须遵守适用法律及相关许可或授权。工具不会判定著作权归属，也不会核实您的预期使用方式是否获准。

4. **保留信息不等于核实权利。** 生成 EPUB 时，工具会尽量保留源图书的署名与权利元数据。这并不代表翻译或分发已获许可。请检查输出内容，并按适用许可或授权的要求，保留或补充署名、版权声明、许可信息及修改说明。

5. **不提供担保；责任限制。** 软件按本项目 MIT 许可证的规定“按原样”提供，不附带任何担保。在适用法律允许的范围内，作者及著作权人按该许可证的规定，不承担因使用软件而产生的责任。

如对项目有问题或建议，请通过 issue 联系我们。

# Disclaimer

bilingual_book_maker helps users create multilingual versions of books and other documents.

1. **Use only material you are entitled to translate.** This includes works for which you hold the necessary rights, works whose license or the rights holder's permission allows your intended use, works in the public domain under applicable law, and uses otherwise permitted by applicable law. Owning or buying a copy does not itself grant translation rights.

2. **Check translation and distribution rights separately.** Translation is generally an adaptation (a derivative work). Personal use is not automatically exempt from copyright restrictions. If you share a translation, ensure that your license, permission, or applicable law also allows distribution of everything included in the output, including any original text and images. The same license or permission may cover both translation and distribution. For Creative Commons works, check the specific license and version, including attribution, NonCommercial, ShareAlike, and NoDerivatives conditions where applicable. For example, NoDerivatives licenses do not authorize public sharing of translations. Public-domain status varies by jurisdiction; modern translations, editorial contributions, forewords, and illustrations may be protected separately from an older original.

3. **The MIT license does not license book content.** The project's MIT license covers its software and associated documentation; it grants no rights to input books or other third-party content and does not automatically apply to generated files. Use of the output remains subject to applicable law and any relevant licenses or permissions. The tool does not determine copyright ownership or verify that your intended use is permitted.

4. **Preservation is not rights verification.** For EPUB output, the tool carries over source attribution and rights metadata where it can. This does not establish that translation or distribution is permitted. Check the output and retain or supply attribution, copyright notices, license information, and indications of changes as required by the applicable license or permission.

5. **No warranty; limitation of liability.** The software is provided “as is,” without warranty, as set out in the project's MIT license. To the extent permitted by applicable law, the authors and copyright holders disclaim liability arising from use of the software, as set out in that license.

For questions or suggestions about the project, please open an issue.

---

### Zh Docker (`docs/zh/docker.md`)

# Docker

如果不想自己配置 Python，就用 Docker。每次合并到 `main` 以及每次发布版本标签时，镜像都会发布到 GitHub Container Registry。容器运行的是 `python make_book.py`，所以本站介绍的每个参数都原样可用。

## 镜像与标签

镜像有三个，由同一个 Dockerfile 构建。

| 标签 | 包含什么 | 平台 | 用途 |
|---|---|---|---|
| `latest`（也叫 `basic`） | 基于 `python:3.12-slim` 的翻译程序及其 Python 包；amd64 上约 280 MB | amd64、arm64 | EPUB、TXT、Markdown、SRT，以及走旧纯文本路线的 PDF |
| `pdf` | `latest` 加上 Pandoc 3.11 和 PDF 相关的包，PyTorch 用的是 CPU 版本；amd64 上约 2.4 GB | amd64、arm64 | 在处理器上运行 `--to-epub`：macOS、arm64 Linux，以及任何没有 NVIDIA 显卡的机器 |
| `pdf-cuda` | 官方 PyTorch CUDA 运行时镜像，加上 Pandoc 3.11、翻译程序和 PDF 相关的包；光是基础镜像就要下载 3.4 GB | amd64 | 在 NVIDIA GPU 上运行 `--to-epub`，Linux 或 Windows |
| `<version>`、`<version>-pdf`、`<version>-pdf-cuda` | 某个发布版本的这三个镜像 | | 固定到某个发布版本 |
| `sha-<commit>`、`sha-<commit>-pdf`、`sha-<commit>-pdf-cuda` | 某个提交的这三个镜像 | | 固定到某次构建 |

```bash
docker pull ghcr.io/yihong0618/bilingual_book_maker:latest
```

## 挂载

- **把你的书所在文件夹挂载到 `/book`。**以 `/book/<file>` 的形式传入书名。译好的书会写回同一个文件夹。
- **把一个命名卷挂载到 `/root/.cache`**（`pdf` 和 `pdf-cuda` 镜像）。docling 的模型在第一次 `--to-epub` 运行时下载到这里，约 500 MB。没有这个卷的话，每次运行都会重新下载。

容器以 root 身份运行，所以往挂载的文件夹里写入总是可以的。在 Linux 上，它写出的文件归 root 所有：事后 `chown` 一下，或者加上 `--user $(id -u)`。

key 请用环境变量传入，而不要写在命令行上：`-e OPENAI_API_KEY`。

## 按系统翻译 EPUB

=== "Linux / macOS"

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:latest \
      --book_name /book/my_book.epub \
      --language zh-hans \
      --use_context session
    ```

=== "Windows PowerShell"

    ```powershell
    docker run --rm `
      -v "${PWD}:/book" `
      -e OPENAI_API_KEY `
      ghcr.io/yihong0618/bilingual_book_maker:latest `
      --book_name /book/my_book.epub `
      --language zh-hans `
      --use_context session
    ```

一个完全不需要 key 的测试，走免费的 Google 路线：

```bash
docker run --rm \
  -v "$PWD":/book \
  ghcr.io/yihong0618/bilingual_book_maker:latest \
  --book_name /book/animal_farm.epub \
  --api_format google \
  --test \
  --test_num 1 \
  --language zh-hant
```

## 按系统翻译 PDF

`latest` 镜像里没有 Pandoc，也没有 PDF 相关的包，所以不能运行 `--to-epub`。有 NVIDIA 显卡请使用 `pdf-cuda` 标签，其他情况都用 `pdf`。

=== "带 NVIDIA 显卡的 Linux"

    在宿主机上安装 NVIDIA 驱动和 NVIDIA Container Toolkit，别的都不用装：镜像自带 CUDA 运行时。

    ```bash
    docker run --rm --gpus all \
      -v "$PWD":/book \
      -v bbm-models:/root/.cache \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:pdf-cuda \
      --book_name /book/paper.pdf \
      --to-epub \
      --use_context session
    ```

=== "带 NVIDIA 显卡的 Windows"

    使用 **WSL2 后端**的 Docker Desktop。在 Windows 本身（而不是 WSL 里）安装支持 WSL 的 NVIDIA 驱动。Windows 容器模式访问不到 GPU。

    ```powershell
    docker run --rm --gpus all `
      -v "${PWD}:/book" `
      -v bbm-models:/root/.cache `
      -e OPENAI_API_KEY `
      ghcr.io/yihong0618/bilingual_book_maker:pdf-cuda `
      --book_name /book/paper.pdf `
      --to-epub `
      --use_context session
    ```

=== "仅 CPU"

    `pdf` 镜像带的是 CPU 版本的 PyTorch，amd64 和 arm64 都有。`--device cpu` 跳过加速器检测。输出和在 GPU 上完全一样，只是更慢。

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -v bbm-models:/root/.cache \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:pdf \
      --book_name /book/paper.pdf \
      --to-epub \
      --device cpu \
      --use_context session
    ```

=== "macOS"

    无论传什么参数，容器都只用 CPU：Docker 运行的是一个看不到 Metal 的 Linux 虚拟机。`pdf` 镜像在 Apple 芯片（arm64）上原生运行。在那里，[本机安装](installation-pdf.md)更快（MPS）。

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -v bbm-models:/root/.cache \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:pdf \
      --book_name /book/paper.pdf \
      --to-epub \
      --device cpu \
      --use_context session
    ```

## arm64 上的 NVIDIA 显卡

`pdf-cuda` 只为 amd64 构建：它所基于的官方 PyTorch CUDA 镜像只发布了这一种架构。在带显卡的 arm64 Linux 主机（GH200、Jetson）上，`pdf` 在处理器上运行。

## 所有镜像里都没有的

Codex 路线（`--api_format codex`）。它驱动的是你机器上已登录的 `codex` 程序；容器里既没有这个程序，也没有登录状态。在 Docker 里请使用 API 路线。

## 自己构建

直接构建得到的是小镜像：

```bash
docker build --tag bilingual_book_maker .
```

PDF 镜像是 `pdf` 和 `pdf-cuda` 阶段：

```bash
docker build --target pdf --tag bilingual_book_maker:pdf .
docker build --platform linux/amd64 --target pdf-cuda --tag bilingual_book_maker:pdf-cuda .
```

---

### Zh Env Settings (`docs/zh/env_settings.md`)

# 环境变量设置

把凭据写进环境变量，就可以省掉 `--key`。

## API key

`BBM_API_KEY` 对每种格式都有效，所以通常一个变量就够了：

```
export BBM_API_KEY=${your_api_key}
```

没有设置它时，每种格式还会去看人们为该厂商习惯导出的那个变量：

| `--api_format` | 备用变量 |
|---|---|
| `openai` | `OPENAI_API_KEY`、`BBM_OPENAI_API_KEY` |
| `anthropic` | `ANTHROPIC_API_KEY`、`BBM_CLAUDE_API_KEY` |
| `gemini` | `BBM_GOOGLE_GEMINI_KEY`、`GEMINI_API_KEY` |
| `qwen` | `BBM_QWEN_API_KEY`、`DASHSCOPE_API_KEY` |
| `groq` | `BBM_GROQ_API_KEY`、`GROQ_API_KEY` |
| `xai` | `BBM_XAI_API_KEY`、`XAI_API_KEY` |
| `litellm` | `BBM_LITELLM_API_KEY`、`LITELLM_MASTER_KEY` |
| `caiyun` | `BBM_CAIYUN_API_KEY` |
| `deepl` | `BBM_DEEPL_API_KEY` |

`google`、`deeplfree`、`tencent` 和 `customapi` 不需要 key，localhost 上的端点也不需要——包括那里的 LiteLLM 代理，除非 `--api_base` 另有指定，`--api_format litellm` 指向的就是它。

命令行工具不读 `.env` 文件。请先导出变量，或者在运行前 source 一个被 git 忽略的文件：`set -a; source .env; set +a; bbook_maker ...`

## 提供方自己的变量

`--provider NAME` 从工作目录下的 `bbm_providers.json` 读取端点，没有则读 `~/.bbm/providers.json`。条目里有 `env_key` 时，会先查它指定的变量，再查 `BBM_API_KEY` 和上面的备用变量——它指明的是正在调用的端点，所以它自己的 key 才是对的。只有在运行仍然调用那个端点时才会查它：`--api_base` 指向别处，或者在没有写 `base_url` 的条目上用 `--api_format` 覆盖格式，都会把请求移到另一台主机，条目的 key 不会发到那里。运行会说明这一点；如果你本意是继续用它，请传 `--key`。文件里只有地址和变量名，从不存放密钥。见[端点、模型与语言](./model_lang.md#命名端点-provider)。`--model orcarouter` 以同样方式读取 `BBM_ORCAROUTER_API_KEY`。

## 分类器变量

[Jev 兼容分类器](providers.md#jev-与-jev-兼容分类器)只为它自己的主机读取 key：

| 变量 | 用于 |
|---|---|
| `JEV_API_KEY`、`TYPESAFE_API_KEY` | typesafe.ai 地址上 TypeSafe 的 Jev |
| `CF_AIG_TOKEN` | gateway.ai.cloudflare.com 上开启认证的 Cloudflare AI Gateway |

通过网关访问时，用 `--classify-key` 或提供方条目的 `classify_env_key` 指定 key。

`BBM_JEV_MIN_CONFIDENCE` 是 0 到 1 之间的数，在不写 `--classify-min-confidence` 时为单次运行设置 Jev 的闸门（两者都有时以参数为准）：低于它的 `skip` 会被翻译。默认是 0.95。不是 0 到 1 之间的数时，运行会停下。

## 旧的各厂商变量

旧式命令暗示了对应路线时，上面四个厂商变量同样会被读取——`--model gemini` 会查 `BBM_GOOGLE_GEMINI_KEY`，尽管改写后的命令写的是格式（见[从旧参数迁移](migration.md)）。它们绝不会被用于 `--api_base` 指向其他端点的命令：那种情况下 key 必须与地址相符，只有 `BBM_API_KEY` 和该格式自己的变量适用。

## 覆盖提示词

```
export BBM_CHATGPTAPI_USER_MSG_TEMPLATE=${your_prompt_template}
export BBM_CHATGPTAPI_SYS_MSG=${your_system_message}
```

---

### Zh Installation Pdf (`docs/zh/installation-pdf.md`)

# 安装 PDF 扩展

如果你想把 PDF 变成双语 EPUB（`--to-epub`），请在仓库的克隆里安装 PDF 扩展：

```bash
pip install ".[pdf]"
```

`--to-epub` 用 [docling](https://github.com/docling-project/docling) 的版面模型和表格模型读取 PDF，所以它需要的比基础安装多：PyTorch、模型，以及用来生成 EPUB 的 Pandoc。这些默认都不安装，因为大多数人翻译的是 EPUB，从来不碰 PDF。不需要 Java；提到 JRE 或 Adoptium 的说明都已过时。

## 1. 获取代码

这条路线还没有进入已发布的包，所以要从克隆安装。

```bash
git clone https://github.com/yihong0618/bilingual_book_maker.git
```

```bash
cd bilingual_book_maker
```

建议使用虚拟环境。

## 2. Pandoc

PATH 上需要有 Pandoc **3.1.12 或更新版本**。它负责生成 EPUB 及其导航。更早的版本会让目录指向文件而不是标题，工具会在打开 PDF 之前就拒绝它们。Ubuntu 24.04 和 Debian 13 上的 apt 包都比这个版本旧，所以请从 [pandoc.org/installing.html](https://pandoc.org/installing.html) 获取发布版。

```bash
pandoc --version
```

## 3. PDF 扩展

请安装扩展，而不是锁定版本的 requirements 文件：锁定文件把 PyTorch 和其他所有东西都钉在确切的版本上，所以即使你已经装了 torch，它们也会再下载一遍；扩展则复用已经装好的。

装到哪个 PyTorch 版本，取决于它来自哪个*索引*，而不是版本号。选择你的系统：

=== "macOS（Apple 芯片）"

    ```bash
    pip install ".[pdf]"
    ```

    你会得到 MPS 加速。没有什么要选的。

=== "带 NVIDIA 显卡的 Linux"

    ```bash
    pip install ".[pdf]"
    ```

    PyPI 上的 Linux wheel *就是* CUDA 版，所以这样就够了。不需要 CUDA Toolkit；wheel 自带运行时。

=== "Linux，只有 CPU"

    ```bash
    pip install ".[pdf]" \
        --extra-index-url https://download.pytorch.org/whl/cpu
    ```

    不加 CPU 索引，你会装上约 3.2 GB 用不上的 CUDA。加上它，约 380 MB，而且没有 `nvidia-*` 包。

=== "带 NVIDIA 显卡的 Windows"

    ```bat
    pip install ".[pdf]" ^
        --extra-index-url https://download.pytorch.org/whl/cu126
    ```

    **PyPI 上的 Windows wheel 只支持 CPU。** Windows 的 CUDA 版只发布在 PyTorch 自己的索引上，所以普通的那行命令会让你悄无声息地停留在处理器上。更新的显卡和驱动可以用 `cu128`。CUDA wheel 约 2.7 GB。

    你还需要 NVIDIA 驱动。PyTorch 自带 CUDA 运行时，所以你**不**需要 CUDA Toolkit，但驱动要你自己安装：

    - 驱动下载：<https://www.nvidia.com/en-us/drivers/>（Game Ready 或 Studio 都可以）。
    - PyTorch 的安装矩阵，用来确认适合你显卡的渠道：<https://pytorch.org/get-started/locally/>。

    安装 PyTorch 之前先检查驱动：

    ```bat
    nvidia-smi
    ```

    它会打印驱动版本和它支持的最高 CUDA 版本。如果这个数字低于你选的渠道（`cu126` 对应 12.6），就更新驱动。在有显卡的机器上 `torch.cuda.is_available()` 却返回 `False`，通常就是驱动太旧。

=== "Windows，只有 CPU"

    ```bat
    pip install ".[pdf]"
    ```

    在 Windows 上这会给你 CPU 版，因为 PyPI 在那里发布的就是它。

请用 `--extra-index-url`，不要用 `--index-url`。`--index-url` 会*替换*掉 PyPI，这个工具需要的其他所有包就都解析不到了。

如果你想要与项目测试时完全相同的版本，`requirements-pdf-gpu.txt` 和 `requirements-pdf-cpu.txt` 就是 Docker 镜像安装的那两套锁定版本（`pdf` 用 CPU 那份，`pdf-cuda` 用 GPU 那份、去掉基础镜像里已有的 PyTorch；`pip install -r requirements-pdf-cpu.txt` 的文件里已经写明了 CPU 索引）。它们会替换掉你已有的 PyTorch。

### 使用 uv

```bash
uv pip install ".[pdf]"
```

想要 CPU 版时：

```bash
uv pip install ".[pdf]" --torch-backend=cpu
```

## 4. 运行

先读两页。`--test` 只翻译少数几段。

```bash
python make_book.py \
  --book_name paper.pdf \
  --to-epub \
  --pages 1-2 \
  --test
```

然后完整运行：

```bash
python make_book.py \
  --book_name paper.pdf \
  --to-epub \
  --use_context session
```

模型（约 500 MB）在第一次运行时下载，所以第一份 PDF 会比第二份明显慢一些。下载期间进度行会一直在走。模型来自 Hugging Face，所以可以用 `HF_HOME` 换个缓存位置：

```bash
export HF_HOME=/path/with/room
```

用 `--pdf-ocr` 读取扫描件不会再下载别的东西：pdf 扩展会连同模型一起安装 rapidocr，在 Mac 上还会安装 Apple 的引擎 ocrmac，`auto` 在那里会选它。[选择 OCR 引擎](features/pdf-ocr-engines.md)对它们做了比较。

模型缓存好以后，提取就不需要网络了。[PDF 推荐设置](features/recommended-pdf.md)按文档类型给出了对应的命令。

## 检查装到了什么

```bash
python -c "import torch; print(torch.__version__, torch.version.cuda)"
```

- 版本号以 `+cpu` 结尾，后面是 `None`：CPU 版。走 CPU 路线时这是对的。
- 类似 `12.6` 的 CUDA 版本：CUDA 版。这时 `torch.cuda.is_available()` 会告诉你这台机器能否用上它。`False` 表示这个版本带有 CUDA，但没有可用的显卡，或者驱动太旧。
- 在 Apple 芯片上，`None` 是对的：MPS 不是 CUDA。用 `python -c "import torch; print(torch.backends.mps.is_available())"` 检查它。
- 在带 NVIDIA 显卡的 Windows 上，`None` 表示你装的是 CPU 版，PyPI 在那里发布的就是它。回到第 3 步。

`--device cuda` 能把这两种失败区分开，因为它们的解决办法不同：只支持 CPU 的版本要重新安装；没有显卡的机器则不是重装能解决的。

## 不克隆直接安装：暂时不行

`pip install "bbook_maker[pdf]"` **不会**安装这条路线，也不会报错。pip 把未知的扩展当作警告处理，安装不带 docling 的最新发布版，然后以 0 退出：

```text
WARNING: bbook-maker 1.2.1 does not provide the extra 'pdf'
Successfully installed bbook-maker-1.2.1
```

下一次处理 PDF 时，运行会以缺少扩展的信息拒绝。在某个发布版包含这条路线之前，请克隆仓库（第 1 步），并从克隆安装 `".[pdf]"`。

## 保留 CPU 版

`torch==…+cpu` 满足任何 `torch>=…` 的要求，所以没有什么会强制替换它。但下一次不带索引、又涉及 PyTorch 的 `pip install -U` 会拉取 CUDA wheel，带进约 3 GB 的内容。让这个索引固定在环境里：

```bash
export PIP_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu
```

或者写进 `pip.conf` / `pip.ini`：

```ini
[global]
extra-index-url = https://download.pytorch.org/whl/cpu
```

uv 的对应写法是 `UV_TORCH_BACKEND=cpu`。

## 或者全部跳过：Docker

`pdf` 镜像标签带有 Pandoc 和完整的 docling 运行环境，PyTorch 用的是 CPU 版本：`docker pull ghcr.io/yihong0618/bilingual_book_maker:pdf`。有 NVIDIA 显卡的话，用 `pdf-cuda` 标签。见 [Docker 安装](docker.md)，其中也解释了为什么 Mac 应该直接在本机安装。

## 大小

PDF 这一步在 PyTorch 2.7.1 下要下载的内容，按各平台分别解析（102 个包）：

| | 下载量 |
|---|---|
| macOS，Apple 芯片 | **~270 MB** |
| Linux x86_64，CPU 版 | **~380 MB** |
| Linux x86_64，CUDA 版 | **~3.2 GB** |
| Windows x86_64，PyPI（CPU 版） | **~420 MB** |
| Windows x86_64，`cu126` | **~2.9 GB** |

另外第一次运行时还有 **~500 MB 的模型**，所有平台都一样。

差异主要来自 PyTorch。依赖树的其余部分在各平台上都约为 200 MB（opencv 48 MB，scipy 29 MB，rapidocr 27 MB，transformers、numpy、pandas……）。单看 PyTorch wheel：

| | torch 2.7.1 wheel |
|---|---|
| macOS arm64 | 68.6 MB |
| Linux x86_64，`+cpu` | 175.8 MB，其元数据中**没有**声明任何 `nvidia-*` 依赖 |
| Linux x86_64，PyPI 默认 | 821.0 MB，**另加 ~2.16 GB** 的 `nvidia-*` 和 `triton` wheel |
| Windows x86_64，PyPI | 216.0 MB（CPU 版） |
| Windows x86_64，`cu126` | 2.72 GB |

两个平台恰好相反，这正是陷阱所在：在 Linux 上默认是 CUDA，要主动*退出*；在 Windows 上默认是 CPU，要主动*加入*。macOS 没有 CUDA 版本；它那个小小的 PyTorch 带的是 Metal 内核，没有 CUDA，也不缺任何东西。

## 如果不能用

下面是工具会打印的信息，以及该怎么做。

- **`reading a PDF needs the pdf extra, which is not installed.`** 第 3 步没有做。如果你运行的是 `pip install "bbook_maker[pdf]"`，而它说安装成功了，那就是上面说的陷阱。
- **`Pandoc is required for --to-epub. Install it and make sure pandoc is on PATH.`** 做第 2 步。
- **`… is too old for EPUB export; Pandoc 3.1.12 or newer is required`** 你的 Pandoc 来自 apt。从 pandoc.org 安装发布版（第 2 步），并把它放在 PATH 的最前面；这条信息还提到了另一种办法：分步脚本 `tools/pdf_to_book.py --pandoc PATH`。
- **`--device cuda was asked for, but the installed PyTorch is a CPU-only build.`** 用 CUDA 版重新安装扩展：Linux 上用普通的那行命令，Windows 上用 `cu126` 索引（第 3 步）。
- **`--device cuda was asked for, but this machine has no cuda accelerator available.`** 这个版本带有 CUDA，但机器或驱动提供不了。运行 `nvidia-smi`：没有输出说明没有驱动；CUDA 版本低于你的渠道说明驱动太旧。否则就用 `--device cpu`。
- **在 Windows 上，机器明明有显卡，`torch.version.cuda` 却是 `None`。** 用的是普通的那行命令。指定 `cu126` 索引重新安装（第 3 步）。
- **`… selected pages have no text layer …; rerun with --pdf-ocr …`** 这份 PDF 是扫描件。加上 `--pdf-ocr`；如果扫描件不是中文或英文，再加上 `--ocr-lang`。见 [PDF 推荐设置](features/recommended-pdf.md)。
- **在 Docker 里，ARM 机器上的 `--gpus all` 好像被忽略了。** 确实如此：arm64 镜像里的 PyTorch 只支持 CPU。加上 `--platform linux/amd64`。
- **在 Mac 上的 Docker 里，GPU 从来用不上。** 这是对的，也没法解决：Linux 虚拟机看不到 Metal。要用 MPS，请直接在本机安装（第 1 到 4 步）。

---

### Zh Installation (`docs/zh/installation.md`)

# 安装

bilingual book maker 是一个 Python 程序，需要 Python 3.10 或更新版本。用虚拟环境可以把它和你的其他包隔开。

## 从 PyPI 安装

```bash
pip install -U bbook_maker
```

这样你就有了 `bbook_maker` 命令。它能翻译 EPUB、TXT、Markdown 和 SRT 文件，也能用旧的纯文本路线处理 PDF。

发布的包暂时还不带 PDF 转 EPUB 路线。如果需要它，请从仓库源码安装（见下文），再加上 [PDF 扩展](installation-pdf.md)。

## 从仓库源码安装

想用最新代码或 PDF 转 EPUB 路线，就从源码安装。

```bash
git clone https://github.com/yihong0618/bilingual_book_maker.git
```

```bash
cd bilingual_book_maker
```

```bash
pip install -r requirements.txt
```

`requirements.txt` 是锁定并测试过的一组版本。`pip install .` 也可以，装的是当下能解析到的版本。从源码运行时命令是 `python make_book.py`，参数与 `bbook_maker` 相同。

## PDF 扩展

如果要把 PDF 转成双语 EPUB（`--to-epub`），请在同一份源码里加装 PDF 扩展。它会带来 docling 和 PyTorch；模型（约 500 MB）在首次运行时下载，另外还需要 Pandoc 3.1.12 或更新版本。

```bash
pip install ".[pdf]"
```

这个扩展会复用你已经装好的 PyTorch，而不是另外下载一个锁定版本。上面这行命令适用于 Apple 芯片、带 NVIDIA GPU 的 Linux，以及不带 NVIDIA GPU 的 Windows。不带 NVIDIA GPU 的 Linux 要加上 PyTorch 的 CPU 索引，否则会下载约 3 GB 用不上的 CUDA；带 NVIDIA GPU 的 Windows 要加上 PyTorch 的 CUDA 索引。[安装 PDF 扩展](installation-pdf.md)列出了每种情况的命令。

不要运行 `pip install "bbook_maker[pdf]"`。发布的包还不带这条路线：pip 只会给出警告，然后安装不含它的发布版。

## Docker

如果不想安装 Python 包，就用发布的镜像，见 [Docker](docker.md)。

## 检查安装

```bash
bbook_maker --help
```

从源码运行时：

```bash
python make_book.py --help
```

每个参数都以帮助文本为准。[命令行参数](cmd.md)把它们列在一张表里。

---

### Zh Languages (`docs/zh/languages.md`)

# 语言

`--language` 接受这张表里任何一行的任意一种写法：标签（`--language zh-hant`）或名称（`--language "traditional chinese"`，不区分大小写）。标签是机械用途的那一半——标在插入的标记和 `dc:language` 上，记录在翻译元数据记录里，并作为结构化输出回复字段的名字（`zh_hant_translation`）。名称是文字那一半：真正向模型提出的要求。

这张表里没有的语言，请自己以 `TAG:NAME` 的形式同时给出两半，按第一个冒号拆分——例如 `--language "ain:Ainu"`。这时标签驱动标注和字段名，就像它列在这里一样；名称交给模型。随手输入、什么都不匹配的名称仍然可以运行——运行会打印一行 `Note:`，说明输出上不会标注任何东西。

源语言从不属于 `--language`；自动检测不够用时，用 `--source_lang` 指定它。

## 标签

| 标签 | 名称 |
| --- | --- |
| `aa` | afar |
| `ab` | abkhaz |
| `ace` | acehnese |
| `af` | afrikaans |
| `ak` | akan |
| `akk` | akkadian |
| `am` | amharic |
| `an` | aragonese |
| `ang` | old english |
| `apc` | levantine arabic |
| `ar` | arabic |
| `arc` | aramaic |
| `arn` | mapudungun |
| `ary` | moroccan arabic |
| `arz` | egyptian arabic |
| `as` | assamese |
| `ast` | asturian |
| `av` | avar |
| `ay` | aymara |
| `az` | azerbaijani |
| `ba` | bashkir |
| `ban` | balinese |
| `bcl` | bikol |
| `be` | belarusian |
| `bg` | bulgarian |
| `bho` | bhojpuri |
| `bi` | bislama |
| `bm` | bambara |
| `bn` | bengali |
| `bo` | tibetan |
| `br` | breton |
| `brx` | bodo |
| `bs` | bosnian |
| `bug` | buginese |
| `ca` | catalan |
| `ce` | chechen |
| `ceb` | cebuano |
| `ch` | chamorro |
| `chr` | cherokee |
| `ckb` | central kurdish |
| `co` | corsican |
| `cop` | coptic |
| `cr` | cree |
| `cs` | czech |
| `csb` | kashubian |
| `cu` | church slavonic |
| `cv` | chuvash |
| `cy` | welsh |
| `da` | danish |
| `de` | german |
| `de-at` | austrian german |
| `de-ch` | swiss standard german |
| `doi` | dogri |
| `dsb` | lower sorbian |
| `dv` | dhivehi |
| `dz` | dzongkha |
| `ee` | ewe |
| `egy` | ancient egyptian |
| `el` | greek |
| `en` | english |
| `en-au` | australian english |
| `en-ca` | canadian english |
| `en-gb` | british english |
| `en-in` | indian english |
| `en-us` | american english |
| `enm` | middle english |
| `eo` | esperanto |
| `es` | spanish |
| `es-419` | latin american spanish |
| `es-ar` | argentine spanish |
| `es-es` | european spanish |
| `es-mx` | mexican spanish |
| `et` | estonian |
| `eu` | basque |
| `fa` | persian |
| `ff` | fulah |
| `fi` | finnish |
| `fil` | filipino |
| `fj` | fijian |
| `fo` | faroese |
| `fr` | french |
| `fr-ca` | canadian french |
| `fro` | old french |
| `fur` | friulian |
| `fy` | western frisian |
| `ga` | irish |
| `gd` | scottish gaelic |
| `gl` | galician |
| `gn` | guarani |
| `goh` | old high german |
| `got` | gothic |
| `grc` | ancient greek |
| `gsw` | swiss german |
| `gu` | gujarati |
| `gv` | manx |
| `ha` | hausa |
| `hak` | hakka |
| `haw` | hawaiian |
| `he` | hebrew |
| `hi` | hindi |
| `hil` | hiligaynon |
| `hit` | hittite |
| `hr` | croatian |
| `hsb` | upper sorbian |
| `ht` | haitian creole |
| `hu` | hungarian |
| `hy` | armenian |
| `ia` | interlingua |
| `id` | indonesian |
| `ie` | interlingue |
| `ig` | igbo |
| `ilo` | ilocano |
| `io` | ido |
| `is` | icelandic |
| `it` | italian |
| `iu` | inuktitut |
| `ja` | japanese |
| `jam` | jamaican patois |
| `jbo` | lojban |
| `jw` | javanese |
| `ka` | georgian |
| `kab` | kabyle |
| `kam` | kamba |
| `kbd` | kabardian |
| `kg` | kongo |
| `ki` | kikuyu |
| `kk` | kazakh |
| `kl` | greenlandic |
| `km` | khmer |
| `kn` | kannada |
| `ko` | korean |
| `kok` | konkani |
| `ks` | kashmiri |
| `ku` | kurdish |
| `kv` | komi |
| `kw` | cornish |
| `ky` | kyrgyz |
| `la` | latin |
| `lad` | ladino |
| `lb` | luxembourgish |
| `lez` | lezgian |
| `lg` | ganda |
| `lij` | ligurian |
| `lld` | ladin |
| `lmo` | lombard |
| `ln` | lingala |
| `lo` | lao |
| `lt` | lithuanian |
| `lu` | luba-katanga |
| `luo` | luo |
| `lv` | latvian |
| `lzh` | literary chinese |
| `mad` | madurese |
| `mai` | maithili |
| `mer` | meru |
| `mg` | malagasy |
| `mi` | maori |
| `min` | minangkabau |
| `mk` | macedonian |
| `ml` | malayalam |
| `mn` | mongolian |
| `mni` | manipuri |
| `mr` | marathi |
| `ms` | malay |
| `mt` | maltese |
| `mwl` | mirandese |
| `my` | myanmar |
| `myv` | erzya |
| `nah` | nahuatl |
| `nan` | hokkien |
| `nap` | neapolitan |
| `nb` | norwegian bokmal |
| `nds` | low german |
| `ne` | nepali |
| `new` | newari |
| `nl` | dutch |
| `nl-be` | belgian dutch |
| `nn` | nynorsk |
| `no` | norwegian |
| `non` | old norse |
| `nr` | southern ndebele |
| `nso` | northern sotho |
| `nv` | navajo |
| `ny` | chichewa |
| `nyn` | nyankole |
| `oc` | occitan |
| `oj` | ojibwe |
| `om` | oromo |
| `or` | odia |
| `os` | ossetian |
| `pa` | punjabi |
| `pal` | middle persian |
| `pam` | kapampangan |
| `pap` | papiamento |
| `pi` | pali |
| `pl` | polish |
| `pms` | piedmontese |
| `ps` | pashto |
| `pt` | portuguese |
| `pt-br` | brazilian portuguese |
| `pt-pt` | european portuguese |
| `qu` | quechua |
| `rm` | romansh |
| `rn` | kirundi |
| `ro` | romanian |
| `ru` | russian |
| `rue` | rusyn |
| `rw` | kinyarwanda |
| `sa` | sanskrit |
| `sah` | yakut |
| `sat` | santali |
| `sc` | sardinian |
| `scn` | sicilian |
| `sco` | scots |
| `sd` | sindhi |
| `se` | northern sami |
| `sg` | sango |
| `sga` | old irish |
| `shn` | shan |
| `si` | sinhala |
| `sk` | slovak |
| `sl` | slovenian |
| `sm` | samoan |
| `sn` | shona |
| `so` | somali |
| `sq` | albanian |
| `sr` | serbian |
| `sr-cyrl` | serbian cyrillic |
| `sr-latn` | serbian latin |
| `srn` | sranan tongo |
| `ss` | swati |
| `st` | southern sotho |
| `su` | sundanese |
| `sux` | sumerian |
| `sv` | swedish |
| `sw` | swahili |
| `syr` | syriac |
| `szl` | silesian |
| `ta` | tamil |
| `te` | telugu |
| `tg` | tajik |
| `th` | thai |
| `ti` | tigrinya |
| `tk` | turkmen |
| `tl` | tagalog |
| `tn` | tswana |
| `to` | tongan |
| `tpi` | tok pisin |
| `tr` | turkish |
| `ts` | tsonga |
| `tt` | tatar |
| `ty` | tahitian |
| `tyv` | tuvan |
| `tzm` | central atlas tamazight |
| `udm` | udmurt |
| `ug` | uyghur |
| `uk` | ukrainian |
| `umb` | umbundu |
| `ur` | urdu |
| `uz` | uzbek |
| `ve` | venda |
| `vec` | venetian |
| `vi` | vietnamese |
| `vo` | volapuk |
| `wa` | walloon |
| `war` | waray |
| `wo` | wolof |
| `wuu` | wu chinese |
| `xcl` | classical armenian |
| `xh` | xhosa |
| `yi` | yiddish |
| `yo` | yoruba |
| `yua` | yucatec maya |
| `zh` | chinese |
| `zh-cn` | mainland chinese |
| `zh-hans` | simplified chinese |
| `zh-hant` | traditional chinese |
| `zh-hk` | hong kong chinese |
| `zh-sg` | singapore chinese |
| `zh-tw` | taiwan mandarin |
| `zh-yue` | cantonese |
| `zu` | zulu |

## 其他写法

上表印出的名称之外，下面这些名称也被接受，对应同一个标签。

| 可以输入 | 标签 |
| --- | --- |
| `bahasa indonesia` | `id` |
| `bahasa melayu` | `ms` |
| `bokmal` | `nb` |
| `brazilian` | `pt-br` |
| `burmese` | `my` |
| `castilian` | `es` |
| `chewa` | `ny` |
| `classical chinese` | `lzh` |
| `divehi` | `dv` |
| `farsi` | `fa` |
| `flemish` | `nl` |
| `frisian` | `fy` |
| `haitian` | `ht` |
| `irish gaelic` | `ga` |
| `isixhosa` | `xh` |
| `isizulu` | `zu` |
| `jamaican creole` | `jam` |
| `judeo-spanish` | `lad` |
| `kalaallisut` | `kl` |
| `kirghiz` | `ky` |
| `letzeburgesch` | `lb` |
| `maldivian` | `dv` |
| `mandarin` | `zh` |
| `mandarin chinese` | `zh` |
| `min nan` | `nan` |
| `modern standard arabic` | `ar` |
| `moldavian` | `ro` |
| `moldovan` | `ro` |
| `norwegian nynorsk` | `nn` |
| `nyanja` | `ny` |
| `old church slavonic` | `cu` |
| `oriya` | `or` |
| `panjabi` | `pa` |
| `pilipino` | `fil` |
| `pushto` | `ps` |
| `putonghua` | `zh` |
| `scots gaelic` | `gd` |
| `sesotho` | `st` |
| `setswana` | `tn` |
| `shanghainese` | `wuu` |
| `sinhalese` | `si` |
| `sorani` | `ckb` |
| `standard chinese` | `zh` |
| `swiss high german` | `de-ch` |
| `taiwanese hokkien` | `nan` |
| `twi` | `ak` |
| `uighur` | `ug` |
| `valencian` | `ca` |

---

### Zh Llm Args (`docs/zh/llm-args.md`)

# 用大模型翻译

有 OpenAI key 的话，默认路线什么都不用再配：

```bash
export OPENAI_API_KEY=sk-...
bbook_maker --book_name my_book.epub --use_context session
```

本页介绍决定请求发往*哪里*、请求*要什么*的参数。它们对每种输入格式都一样；各格式额外的参数见[格式](formats/epub.md)各页。

## 路线：模型、端点、格式

路线指的是端点，而不是模型名。三个参数确定一条路线。

| 参数 | 作用 |
|---|---|
| `--model`（`-m`） | 模型 ID，按端点自己的写法：`gpt-5-mini`、`claude-sonnet-4-6`、`openai/gpt-5-mini`。`openai` 格式下默认 `gpt-6-luna`。`anthropic` 格式必须写。 |
| `--api_base` | 端点 URL。默认是该格式的官方地址。OpenAI 形式的端点以 `/v1` 结尾。粘贴进来的 `…/v1/chat/completions` 或末尾的斜杠会被去掉。 |
| `--api_format` | 端点说的 API：`openai`、`anthropic`、`codex`、`gemini`、`qwen`、`groq`、`xai`、`litellm`，或某个[翻译服务](machine-args.md)引擎。不写时由 `--api_base` 推断：Anthropic 的地址是 `anthropic`，其余都是 `openai`。没写 `--api_base` 而模型 ID 是 `claude-*` 时，也会选 `anthropic`。 |

每种常见情况一个示例：

=== "OpenAI"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --model gpt-6-luna \
      --use_context session
    ```

=== "Anthropic"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --model claude-sonnet-4-6 \
      --use_context session
    ```

=== "任意 OpenAI 兼容端点"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_base https://gateway.example.com/v1 \
      --model provider/model-id \
      --use_context session
    ```

=== "Gemini"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format gemini \
      --model gemini-flash-latest \
      --use_context
    ```

    Gemini 自己维护对话历史，所以用窗口模式（`--use_context`），而不是会话模式。`--interval` 设置请求之间的间隔，免费额度就靠它避开限流。

=== "Codex（ChatGPT 套餐）"

    ```bash
    python make_book.py \
      --book_name my_book.epub \
      --api_format codex
    ```

    通过 [Codex CLI](https://developers.openai.com/codex/cli) 消耗你的 ChatGPT 套餐额度，需要先安装并登录（`codex login`）。默认运行 `gpt-6-luna`，换模型加 `--model <id>`。线程本身就是上下文，所以不需要 `--use_context`。它忽略 `--api_base` 和 `--key`。Codex 回答所选模型已满载时，运行会打印一行 `codex: … retrying in 60 s` 并再次请求；这种回答通常是 Codex 在对它不信任的网络限流，换个网络或账号会有帮助。

提供 Claude 模型的网关通常说的是 OpenAI 形式。如果网关对 anthropic 形式回 404，运行会停下，并指出改用 `--api_format openai` 即可。

`--api_format groq`、`xai` 和 `litellm` 分别是 Groq、xAI 和 LiteLLM 代理（`http://localhost:4000`）上的 OpenAI 形式。每个格式都知道自己的地址，所以格式、key 和 `--model` 就是完整的路线。这几个格式必须写 `--model`。

`--model orcarouter` 把运行交给 OrcaRouter 网关及其 `orcarouter/auto` 模型。key 来自 `--key` 或 `BBM_ORCAROUTER_API_KEY`。

## 每家厂商一条命令

你的厂商如果在下面列出，这就是完整的命令（key 也可以来自环境变量，见 [API key](#api-key)）：

```
/* Detailed source-code truncated for AI context efficiency. */
```

翻译服务（谷歌、DeepL、彩云、腾讯、自定义 API）见[翻译服务](machine-args.md)。常用的厂商最好写进[提供方文件](providers.md)。

## API key

key 按以下顺序查找：

1. `--key`（`--api_key` 是同一个参数）。用逗号分隔的多个 key 会轮流使用，以绕过单个 key 的限流。
2. `BBM_API_KEY`。
3. 该格式自己的变量：`OPENAI_API_KEY`、`ANTHROPIC_API_KEY`、`BBM_GOOGLE_GEMINI_KEY`、`BBM_QWEN_API_KEY`、`BBM_GROQ_API_KEY`、`BBM_XAI_API_KEY`。

localhost 上的端点不需要 key。写在命令行里的 key 会出现在进程列表中，本机其他用户都能看到；尽量用环境变量。本工具自己不读 `.env` 文件，请先加载：

```bash
set -a; source .env; set +a
```

更多变量见[环境变量](env_settings.md)。

## 提供方文件与额外模型

`--provider NAME` 从 JSON 文件而不是参数中读取路线：运行目录下的 `bbm_providers.json`，或 `~/.bbm/providers.json`。两处都没有的名字会退回到随仓库提供的 `bbm_providers.example.json`，运行时会说明这一点。

```bash
bbook_maker \
  --book_name my_book.epub \
  --provider siliconflow \
  --use_context session
```

有两个步骤可以使用翻译模型以外的模型：

| 参数 | 作用 |
|---|---|
| `--classify-model MODEL` | 决定 EPUB 计划的模型，或一个 Jev 兼容的分类器（`jev` 即 TypeSafe 的分类器）。默认：条目的 `classify_model`，否则用翻译模型。`--plan-classify-model` 是它的旧名。 |
| `--classify-base-url URL`、`--classify-key KEY` | 该模型不在本次运行的端点上时，它的地址和 key：一个 OpenAI 兼容地址，或 Jev 兼容分类器的 URL。 |
| `--img-model MODEL` | 查看页面图像的步骤所用的视觉模型（目前是 [PDF 路线](features/pdf-to-epub.md)上纠正区域角色）。默认：条目的 `img_model`，否则关闭。绝不会退回使用翻译模型。`none` 关闭条目里的图像模型。 |
| `--img-base-url URL`、`--img-key KEY` | 该模型的地址和 key（仅限 OpenAI 兼容）。 |

字段、随附的条目、哪个 key 发往哪里，以及 Jev 和 Jev 兼容分类器，见[提供方文件与额外模型](providers.md)。注意随附的 `openai` 条目写了图像模型，所以 `--provider openai` 会开启 PDF 路线的图像步骤。

## 语言

| 参数 | 作用 |
|---|---|
| `--language` | 目标语言。可以是标签（`zh-hant`）、名称（`"Traditional Chinese"`），表里没有该语言时用 `TAG:NAME`（`--language "zh-hant:Traditional Chinese"`）。标签写进输出文件，名称是向模型提要求时的说法。默认 `zh-hans`。完整列表见[语言标签](languages.md)。 |
| `--source_lang` | 源语言，直接指定而不是自动检测。在每条大模型路线上都会写进提示词。默认：自动检测。 |

## 提示词

`--prompt` 接受模板字符串、JSON 字符串，或 `.json`、`.txt`、`.md` 文件。它有三个部分：

- `user`：模板。必填。必须包含 `{text}`；`{language}` 和 `{crlf}` 也会被填入。其他任何占位符都会在运行开始时被拒绝。
- `system`：系统消息。
- `style`：关于语域和语气的说明。它在每个上下文窗口开始处说一次，会话模式下会传给每一个窗口。

裸字符串或 `.txt` 文件就是 `user` 模板。运行时会打印用了哪些部分、各自来自哪里。格式见[提示词文件](prompt.md)。

```bash
bbook_maker \
  --book_name my_book.epub \
  --prompt prompt_template.json \
  --use_context session
```

## 试运行与续跑

| 参数 | 作用 |
|---|---|
| `--test` | 只翻译开头几段，用来检查配置。 |
| `--test_num N` | 翻译多少段（默认 10）。 |
| `--resume` | 从检查点继续一次中断的运行。EPUB 检查点记录了语言、提示词和模型；不一致时续跑会停下。 |

换了新端点，务必先跑一次 `--test`。它几乎不花钱，能让你看到输出格式和运行选中的路线。

## 重试：“耐心”是什么意思

很多人用本工具对接的服务商又慢又限流，首个 token 或一次 429 退避可能要等上几分钟。所以只要错误有可能靠等待消除，工具就绝不放弃。

- 超时、连接中断、429 或 5xx 会不限次数地重试。等待从 1 秒开始，逐次翻倍，涨到 5 分钟后不再增加。
- 每次重试打印一行，长时间等待也不会像是卡住了：
  `retrying after RateLimitError (…) — attempt 4, waiting 8s`
- 等待也不会消除的错误会立刻停止运行：key 被拒、权限错误、端点拒绝的请求、不存在的模型。
- Ctrl+C 总能停止运行，即使正在长时间等待。进度会保存，用 `--resume` 重新运行即可。

如果同一行重试信息持续很久，说明服务商宕机了或正在对你限流。它恢复后，运行会自己继续。

## 本地模型（Ollama、llama.cpp、LM Studio）

本地模型就是你自己机器上的一个 OpenAI 兼容端点。把 `--api_base` 指向它即可，不需要 key。

```bash
bbook_maker \
  --book_name my_book.epub \
  --api_base http://localhost:11434/v1 \
  --model qwen3:8b \
  --use_context session
```

8B 或 16B 模型的预期表现：

- **没有严格的 JSON schema。** 多数本地服务器不强制 schema。运行会用一次探测请求发现这一点并换成分隔符格式，打印类似 `doesn't apply JSON schema … using delimiter method` 的一行。这不是错误。
- **请求更小。** 在没有严格 schema 的端点上，每个请求最多携带 8 个单元，是默认 16 个的一半；不在会话模式下时，文本约 800 token。运行会打印它选定的数字。这些默认值就是按小模型能承受的量选的。
- **计划分类只答一个词。** 计划模式每轮就五种块问模型 `skip` 还是 `translate`。如果模型连续两次没按回答格式作答，运行改为每轮问三种，再改为一种。仍然不行就停止分类，其余部分全部翻译。任何读不懂的回答都按 `translate` 处理，所以弱模型宁可多翻，不会少翻。你也可以用 `--classify-model` 把分类交给托管模型，翻译仍在本地进行；见[提供方文件与额外模型](providers.md#示例)。
- **上下文短。** 把 `--context-compact-at` 设为模型的输入上限（最小 1500），会话窗口就不会溢出。
- **思考型模型。** 回答前先推理的模型可以用 `--no-thinking` 让它别想。运行会从服务器自己的拒绝回复中找出它接受的请求字段。如果你的服务器需要它自己的字段，`--extra_body` 优先，例如 `--extra_body '{"chat_template_kwargs": {"enable_thinking": false}}'`。
- **没有图像步骤。** PDF 路线的图像步骤只在你用 `--img-model` 指定的模型上运行，所以本地模型永远不会被要求读页面。

小模型上应避免的参数：

- `--glossary-auto on`。它需要一个用名称而不是大段文字作答的模型。
- 调高 `--accumulated_num` 或 `--max-batch-units`。运行打印 `misaligned batches this run` 时，应当调低它们。
- `--model_list`。每个模型有自己的缓存，在一段对话里混用模型会损害一致性。

如果你的模型上计划分类总是失败，`--plan-classify all` 会跳过分类、翻译每一个块。见[计划模式](features/plan-mode.md)。

## 其他请求选项

| 参数 | 作用 |
|---|---|
| `--temperature` | 采样温度。anthropic 格式总会发送；openai 格式在它等于 API 默认值或模型不接受温度时不发送；codex 忽略它。 |
| `--no-thinking` | 让模型回答前不要推理；翻译一段文字，思考带不来任何好处，只花 token 和时间。在 OpenAI 形式的路线上，请求字段从端点自己的拒绝回复中找出并记住；如果端点拒绝所有写法，运行警告一次，然后不带它继续。在 anthropic 上是 `thinking: {"type": "disabled"}`。在 codex 上会被拒绝。图像请求也会带上它。 |
| `--extra_body JSON` | 附加到每个请求体的字段（openai 和 anthropic 路线）。这里的字段优先于对应的参数，`--no-thinking` 也不例外。 |
| `--extra_headers JSON` | 附加到每个请求的 HTTP 头（openai 和 anthropic 路线）。 |
| `--model_list IDS` | 轮流使用的多个模型，用来分摊限流。与 `--use_context session` 一起使用会被拒绝。 |
| `--interval SECONDS` | 请求之间的间隔。只有 gemini 格式使用。 |
| `-p`、`--proxy URL` | 本次运行的 HTTP 代理，例如 `http://127.0.0.1:7890`。 |
| `--batch`、`--batch-use` | OpenAI 的 Batch API：先提交任务，之后再用结果生成书。不适用于 EPUB。 |

常见的 `--extra_body` 和 `--extra_headers` 取值：

```bash
# openai route: turn off a local/vLLM chat template's thinking block
--extra_body '{"chat_template_kwargs": {"enable_thinking": false}}'
# openai route (chat completions): reasoning effort and a token ceiling, neither of which has a flag
--extra_body '{"reasoning_effort": "low", "max_completion_tokens": 2000}'
# anthropic route: keep extended thinking off
--extra_body '{"thinking": {"type": "disabled"}}'
# OpenRouter attribution, shown on its dashboard
--extra_headers '{"HTTP-Referer": "https://example.com", "X-Title": "bilingual_book_maker"}'
# a gateway's own auth or routing header
--extra_headers '{"X-API-Key": "sk-gateway-..."}'
```

---

### Zh Machine Args (`docs/zh/machine-args.md`)

# 翻译服务

这些路线调用的是翻译服务：送进去文字，拿回译文。其中有些服务在接口背后用的是语言模型，但都不接受本工具的提示词。它们不需要模型，常常也不需要 key。它们也无法回答问题，所以本工具里一切需要问模型的功能，在这些路线上都是关闭的。

## 路线

用 `--api_format` 选择一条。

| `--api_format` | 服务 | key | 目标语言 |
|---|---|---|---|
| `google` | 谷歌翻译的公开网页端点 | 无 | 大多数语言；源语言自动检测 |
| `deepl` | 通过 RapidAPI 上的 [DeepL Translator](https://rapidapi.com/splintPRO/api/dpl-translator) 使用 DeepL | `--key`，或 `BBM_DEEPL_API_KEY` | DeepL 支持的语言；其他语言会以 `DeepL do not support …` 拒绝 |
| `deeplfree` | DeepL 的免费网页端点 | 无 | DeepL 支持的语言 |
| `caiyun` | [彩云小译](https://fanyi.caiyunapp.com) | `--key`，或 `BBM_CAIYUN_API_KEY` | 简体中文、英语、日语 |
| `tencent` | [腾讯交互翻译](https://transmart.qq.com) | 无 | 中文或英语 |
| `customapi` | 你自己的 HTTP 服务 | 无；地址写在 `--api_base` | 取决于你的服务 |

两条 DeepL 路线都把源语言当作英语发送。请用它们翻译英文书。

彩云提供了一个测试 token `3975l6lr5pcbvidl6jl2`，用来试用这条路线；申请自己的 token，请参考[这个教程](https://bobtranslate.com/service/translate/caiyun.html)。

## 命令

=== "谷歌"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format google \
      --language zh-hant
    ```

=== "DeepL"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format deepl \
      --key ${deepl_key} \
      --language ja
    ```

=== "DeepL 免费版"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format deeplfree \
      --language de
    ```

=== "彩云小译"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format caiyun \
      --key ${caiyun_key} \
      --language zh-hans
    ```

=== "腾讯交互翻译"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format tencent \
      --language zh-hans
    ```

=== "自定义 API"

    ```bash
    bbook_maker \
      --book_name my_book.epub \
      --api_format customapi \
      --api_base https://your.host/translate \
      --language ja
    ```

## 自定义 API 的约定

工具对每个段落向 `--api_base` 发送一个 POST 请求，采用表单编码，带三个字段：`text`、`source_lang` 和 `target_lang`。它从 JSON 回复的 `data` 字段读取译文。每个请求 10 秒超时，请求之间等待 5 秒。`--source_lang` 就是在这里传给你的服务。

## 这些路线做不到的事

- **没有提示词。** `--prompt` 无处可去；文本是单独发送的。
- **没有会话。** `--use_context session` 会被拒绝：没有可以保持的对话。
- **引擎本身不做计划分类。** 在 EPUB 上，运行会退回到 `--translate-tags` 的选择（默认 `p`），所以 `<p>` 之外的诗歌或表格不会被翻译。指定一个大模型分类器，引擎翻译的同时这本书也能得到完整的计划：`--classify-model gpt-6-luna`（它读取 `OPENAI_API_KEY`），或提供方条目的 `classify_model`。这在谷歌翻译上端到端跑通过。没有分类器时，`--plan-classify model` 会被拒绝，而 `--plan-classify all` 仍会翻译计划找到的每一个块，不问任何人。见[计划模式](features/plan-mode.md#选择分类器)。
- **没有术语表。** 只有 OpenAI 形式和 Codex 形式的路线会读 `--glossary`；在这里它会被忽略并给出警告。
- 在 `google`、`deepl`、`deeplfree`、`caiyun` 和 `tencent` 上**不能指定源语言**。`--source_lang` 会被忽略并给出警告；只有 `customapi`（以及 `qwen` 大模型路线）会发送它。

其他都能用：`--test`、`--resume`、`--single_translate`，以及[格式](formats/epub.md)各页上的各格式参数。

## 限流

彩云被限流时会打印 `will sleep 60s for the time limit` 并等待。DeepL 免费版和腾讯会自行在请求之间暂停。[耐心重试](llm-args.md#重试耐心是什么意思)策略适用于每一条路线。

---

### Zh Migration (`docs/zh/migration.md`)

# 从旧参数迁移

为旧命令行写的命令仍然可用。每个已移除的参数都会在运行开始前被改写成新参数，并打印一行说明它变成了什么：

```
$ bbook_maker --book_name book.epub --model gpt4omini --openai_key sk-...
deprecated: --openai_key is now --key
deprecated: --model gpt4omini is now --model gpt-4o-mini
```

| 旧写法 | 改写为 |
|---|---|
| `--model gpt4o` / `gpt4omini` / `o3mini` | `--model` 加该模型的 ID |
| `--model chatgptapi` / `openai` | 去掉：openai 格式就是默认值，想指定模型时用 `--model` 写模型 |
| `--model openai --model_list X` | `--model_list X` |
| `--model claude` | `--model claude-haiku-4-5-20251001` |
| 确切的 `claude-*` ID | 不变；anthropic 格式由 ID 推断 |
| `--model gemini` / `geminipro` | `--api_format gemini --model gemini-flash-latest` / `gemini-pro-latest` |
| `--model qwen` / `qwen-mt-turbo` / `qwen-mt-plus` | `--api_format qwen --model qwen-mt-*` |
| `--model groq --model_list X` | `--api_format groq --model_list X` |
| `--model xai` | `--api_format xai --model grok-4.3` |
| `--model codex` | `--api_format codex`（侧车进程的默认模型；要指定模型就加 `--model`） |
| `--model google` / `caiyun` / `deepl` / `deeplfree` / `tencentransmart` | `--api_format google` / `caiyun` / `deepl` / `deeplfree` / `tencent` |
| `--custom_api URL` | `--api_format customapi --api_base URL` |
| `--openai_key` / `--claude_key` / `--gemini_key` / `--groq_key` / `--xai_key` / `--qwen_key` / `--caiyun_key` / `--deepl_key` / `--orcarouter_key` | `--key`（`--api_key` 是同一个参数，从未改名） |
| `--ollama_model M` | `--api_base http://localhost:11434/v1 --model M` |
| `--deployment_id D` | `--model D`，同时 `--api_base` 被改写为该部署的 `/openai/v1` 路径 |

说明：

- 旧的 key 变量对使用它们的那条路线仍然有效：改写后的 `--model groq` 用 `BBM_GROQ_API_KEY`，改写后的 `--model gemini` 用 `BBM_GOOGLE_GEMINI_KEY`，以此类推。
- 你自己传的参数优先。`--model gemini --api_base https://my-gateway/v1` 会保留你的网关。
- `--interval` 不是旧参数：它仍在解析器里，仍然控制 gemini 路线的请求节奏，而 gemini 仍是一条路线。
- `qwen-mt-turbo` 和 `qwen-mt-plus` 是真实的模型 ID，所以同时传了 `--api_format` 的命令保持原样，也不会为此打印任何东西。
- 其他路线别名在任何地方都不是模型 ID。`--model gemini` 与指向另一条路线的 `--api_format` 一起使用会被拒绝，而不是按其中一方解析：遵从格式，会把该别名的 key 发往不属于它的主机；遵从别名，又会无视你输入的内容。错误信息会列出两者以及两种解决办法。
- 改写后的命令运行的是它过去运行的模型，取自旧的预设列表，而不是更新的模型。其中一些模型已经下线，端点的模型检查会指出这一点。
- 不是旧别名的 `--model` 值会作为模型 ID 原样传递，这在现在是常态。
- 已下线 OpenAI 模型的别名（`gpt4`、`gpt5mini`、`o1`、`o1mini`、`o1preview`）已经去掉。它们现在作为模型 ID 原样传递，由端点按名字拒绝——还是同样的失败，只是早一步发生，信息也更清楚。

---

### Zh Model Lang (`docs/zh/model_lang.md`)

# 端点、模型与语言

路线由它对接的端点决定，而不是由模型名决定。没有需要随时更新的内置模型列表：`--model` 接受你的端点提供的任何 ID。

```sh
bbook_maker --book_name book.epub \
  --api_base https://api.openai.com/v1 --key sk-... \
  --model gpt-5-mini --language ja
```

旧的 `--model` 命令仍然可用——它们会被改写成这些参数，并打印出替换内容。完整对照表见[从旧参数迁移](migration.md)。

## 路线参数

| 参数 | 含义 |
|---|---|
| `--model` | 模型 ID，按端点自己的写法。在 `openai` 格式下可以省略：默认 `gpt-6-luna`。 |
| `--api_base` | 端点 URL。默认是该格式的官方地址；`…/v1`、`…/v1/` 和 `…/v1/chat/completions` 都可以。 |
| `--key` | API key。用逗号分隔多个 key，可以轮流使用以分摊限流。 |
| `--api_format` | 传输格式。会自动推断；只有推断错了才需要传。 |
| `--provider` | 提供方文件里的一个命名端点，代替上面四个参数。 |

`--model_list a,b` 在多个模型之间轮换，旧命令用的也是它；一个模型只在其中一个参数里写，不要两个都写。

`--api_format` 取值为 `openai`（默认）、`anthropic`、`gemini`、`qwen`、`groq`、`xai`、`litellm`、`codex`，或固定的翻译服务 `google`、`caiyun`、`deepl`、`deeplfree`、`tencent`、`customapi`。

五种厂商格式各自带着自己的端点，所以格式加一个 key 就是一条完整的路线，不必再去查 `--api_base`：

| 格式 | 端点 | `--model` |
|---|---|---|
| `gemini` | Gemini API | 可选，默认 `gemini-flash-latest` |
| `qwen` | DashScope 上的 Qwen-MT | 可选，默认 `qwen-mt-turbo` |
| `groq` | `https://api.groq.com/openai/v1` | 必填 |
| `xai` | `https://api.x.ai/v1` | 必填 |
| `litellm` | `http://localhost:4000` | 必填，填代理配置里的名字 |

`gemini` 和 `qwen` 有各自的协议——Gemini 有原生的约束解码、安全设置和对话历史；Qwen-MT 用一对源/目标语言代替提示词——`--interval` 控制 gemini 路线的请求节奏，免费额度就靠它避开限流。另外三种是换了地址的 OpenAI 路线，保留它的一切功能。这五种都不会从主机名推断出来：要么写明格式，要么给出厂商的 OpenAI 兼容 `--api_base`，走 `openai` 路线。

`codex` 根本不是端点：它驱动本地的 `codex app-server` 侧车进程，本次运行记在你的 ChatGPT 套餐上，所以它不接受 `--key` 和 `--api_base`，`--model` 可选（默认 `gpt-6-luna`）。它从不会被推断出来，必须明确写出。见[用大模型翻译](llm-args.md)。

推断按以下顺序进行：明确写出的 `--api_format` 优先；然后看 `--api_base` 的主机（`anthropic.com` 表示 anthropic 形式，其他都是 OpenAI 形式）；然后，在没有指定端点时，看模型 ID 是否提到 `claude` 或 `anthropic`，`anthropic/claude-sonnet-4-6` 也算。

网关被要求使用它并不提供的 anthropic 形式时，会在 `/v1/messages` 上回 404 或 405。运行会停下并指出解决办法：用 `--api_format openai` 重新运行。在 Anthropic 自己的主机上，404 表示模型不存在，也会照此报告。

凭据依次来自 `--key`、`BBM_API_KEY`，然后是该格式惯用的变量——见[环境变量设置](./env_settings.md)。localhost 上的端点不需要 key。

## 命名端点：`--provider`

用到不止一次的端点可以写下来，而不必每次重敲。先读工作目录下的 `bbm_providers.json`，再读 `~/.bbm/providers.json`；同名时项目里的条目优先。

```json
{
  "nvidia": {
    "api_style": "openai",
    "base_url": "https://integrate.api.nvidia.com/v1",
    "default_models": ["moonshotai/kimi-k2-thinking"],
    "env_key": "NVIDIA_API_KEY"
  }
}
```

```sh
bbook_maker --book_name book.epub --provider nvidia --language ja
```

`api_style` 可以是任何指明端点的 `--api_format`——`openai`、`anthropic`、`gemini`、`qwen`、`groq`、`xai`、`litellm`——另外 `claude` 是 `anthropic` 的旧写法。其他任何 OpenAI 兼容主机都是 `openai`，地址写在 `base_url`。随仓库提供的 `bbm_providers.example.json` 为每种都准备了一个条目。明确传入的参数仍然优先于条目，而条目的 `env_key` 只跟着条目的地址走，不会更远。在条目自己的网关上要求另一种传输格式，并没有移动任何东西，所以仍会读取这个 key。`--api_base` 指向别处就会移动这次运行；在没有 `base_url` 的条目上用 `--api_format` 覆盖格式也会，因为这时地址是由格式提供的——key 不会为另一家厂商的主机读取，运行会说明它在比较哪两个地址。如果你本意是继续用它，请传 `--key`。`default_models` 只有一个 ID 时充当 `--model`，有多个时充当 `--model_list`；`env_key` 指明从哪个变量读取 key；文件里不放任何密钥。明确传入的一切都优先，所以 `--provider nvidia --model <id>` 会保留那个模型。未知的名字会报错，并列出两个文件。

## OrcaRouter

```sh
bbook_maker --book_name book.epub --model orcarouter --language ja
```

`--model orcarouter` 把运行交给 OrcaRouter 网关，并请求它的智能路由模型 `orcarouter/auto`。它不需要 `--api_base`，你自己传的会优先。key 先从 `BBM_ORCAROUTER_API_KEY` 读取，然后才是通常的备用变量。这是一条受支持的路线，而不是旧别名，所以不会被改写。要在网关上固定一个模型，就像其他端点一样写明：`--api_base https://api.orcarouter.ai/v1 --model <id>`。

## OpenAI 兼容端点

下面这些都是同一条路线，只是 `--api_base` 不同。结构化输出、`--use_context`、并行 worker、异步和 Batch API 在它们上面都可用，程度取决于端点本身的支持——支持情况在运行时探测，而不是根据模型名假定。

`--use_context session` 还要求端点对缓存的提示 token 收费更低。注意进度条上的 `cached=` 计数：十几个请求之后仍为零，说明端点没有缓存，窗口模式更便宜。

| 厂商 | `--api_base` |
|---|---|
| OpenAI | `https://api.openai.com/v1`（默认） |
| Groq | `https://api.groq.com/openai/v1`，或 `--api_format groq` |
| xAI | `https://api.x.ai/v1`，或 `--api_format xai` |
| Gemini（兼容模式） | `https://generativelanguage.googleapis.com/v1beta/openai/` |
| 阿里 Qwen（DashScope） | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
| SiliconFlow | `https://api.siliconflow.cn/v1` |
| OpenRouter | `https://openrouter.ai/api/v1` |
| Azure OpenAI | 部署的 OpenAI 兼容 URL；`--model` 写部署名 |
| Ollama | `http://localhost:11434/v1` |
| vLLM / LM Studio / llama.cpp | 它们提供服务的任何主机 |

```sh
bbook_maker --book_name book.epub \
  --api_base https://api.groq.com/openai/v1 --key gsk-... \
  --model llama-3.3-70b-versatile
```

在这条路线上，`--extra_body` 用来传递厂商特有的请求字段。

## Anthropic

```sh
bbook_maker --book_name book.epub \
  --api_base https://api.anthropic.com --key sk-ant-... \
  --model claude-sonnet-4-6 --language zh-hans
```

端点提供的任何模型 ID 都接受。Claude 每次运行只用一个模型，所以多出来的 `--model_list` 条目会被说明并忽略，而不是悄悄丢掉。从 OpenAI 风格的 `/v1` base 提供 anthropic 形式的网关也能处理：末尾的 `/v1` 会被去掉，因为 SDK 会自己加上。

通过这种格式分类时使用提示词档位——不会要求端点编译 schema。

## 翻译服务

这些引擎说自己的协议，不接受模型，所以指定模型会报错，而不是悄悄不起作用。

| `--api_format` | 凭据 |
|---|---|
| `google` | 无 |
| `deeplfree` | 无 |
| `tencent` | 无 |
| `customapi` | 无；端点 URL 写在 `--api_base` |
| `caiyun` | 必填 |
| `deepl` | 必填（RapidAPI DeepL Translator） |

它们只翻译文本，别的什么都不做：没有上下文窗口，也没有结构化输出。它们自己无法为计划模式给 EPUB 分类；用 `--classify-model` 指定一个能分类的模型，EPUB 在这些引擎上也能用计划模式。`--source_lang` 会传给 `customapi`（写进请求体）；其他引擎自己检测源语言。（在大模型路线上，这个参数进入提示词——见下文的“语言”。）

## 语言

`--language LANGUAGE` 设置目标语言，默认 `zh-hans`。它接受标签（`zh-hant`）、名称（`"Traditional Chinese"`），或者对于内置表里没有的语言，两者一起写——`--language "zh-hant:Traditional Chinese"`。标签部分是机械用途：标在插入的标记和 `dc:language` 上，记录在翻译元数据里，并作为结构化输出字段的名字。名称部分是提示词里向模型提要求时的说法。单独的标签或名称照旧通过表格解析；不匹配任何标签的值仍然可以运行，启动时打印一条 `Note:`，不标注任何东西。完整的表：[语言标签与名称](languages.md)。

```sh
bbook_maker --help
bbook_maker --book_name book.epub --api_format google --language ja
```

`--source_lang` 直接指定源语言，而不是自动检测；一旦指定，它会进入每条大模型路线的提示词，在 `qwen`/`customapi` 上还会写进请求体。默认值 `auto` 什么都不指定。并不是每个端点都支持解析器接受的每一种语言。

---

### Zh Prompt (`docs/zh/prompt.md`)

# 调整提示词

要调整提示词，请使用 `--prompt` 参数。`user` 模板可用的占位符有 `{text}`（必填）、`{language}` 和 `{crlf}`（换行符，供无法携带换行的形式使用）。花括号里的其他任何内容都会在运行开始前被拒绝；要写字面的花括号，请用 `{{` 和 `}}`。配置提示词有以下几种方式：

- 如果不需要设置 `system` 角色的内容，可以简单地这样写：`--prompt "Translate {text} to {language}."` 或 `--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}
        

- 如果需要设置 `system` 角色的内容，可以使用以下格式：`--prompt '{"user":"Translate {text} to {language}", "system": "You are a professional translator."}'` 或 `--prompt prompt_template.json`

        # prompt_template.json
        {
            "system": "You are a professional book translator. Translate the given paragraphs into {language} and be accurate, faithful, and fluent. Return translated {language} text only.",
            "user": "Translate the following into {language}. Text:{crlf}{crlf}{text}",
            "style": ""
        }

- 第三个键 `style` 是关于怎么写的常驻指令。它在窗口开始时说一次——在 API 路线上随系统消息发出，在 codex 上随线程指令发出——而不是每个请求都重复。随仓库提供的 [`prompt_template.json`](https://github.com/yihong0618/bilingual_book_maker/blob/main/prompt_template.json) 带有全部三个键，`style` 留空：写进你自己想要的文风，或者就让它空着。

- `--prompt` 适用于每一条大模型路线，srt 书也不例外：在那里，它的各个部分逐一叠加在字幕加载器自己的提示词之上；替换 `user` 模板，就意味着你得自己说明块编号和时间轴必须原样返回。

你也可以通过环境变量设置 `user` 和 `system` 角色的提示词：`BBM_CHATGPTAPI_USER_MSG_TEMPLATE` 和 `BBM_CHATGPTAPI_SYS_MSG`（gemini 读取 `BBM_GEMINIAPI_USER_MSG_TEMPLATE` 和 `BBM_GEMINIAPI_SYS_MSG`）。`--prompt` 优先于它们全部。`OPENAI_API_SYS_MSG` 仍会作为后备被读取，但已弃用；`--prompt` 自己的系统消息优先于它。

- `.md` 文件按 [PromptDown](https://github.com/btfranklin/promptdown) 的**块**形式读取。格式是他们定的；读取器是本仓库自己写的，所以不用额外安装包。`--prompt prompt_md.prompt.md`

        # Translation Prompt

        ## System Message

        You are a professional translator who specializes in accurate translations.

        ## Conversation

        **User:**

        Please translate the following text into {language}:

        {text}

  读取三个部分：`## System Message`（也接受 `## Developer Message` 作为它的另一个名字）、可选的 `## Style`，以及
  `## Conversation`，其中第一个 `**User:**` 轮次就是 `user` 模板。写成
  `| Role | Content |` 表格的对话会被拒绝——模板里的换行无法在表格单元格中保留。

## 示例
```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.json
# or
python3 make_book.py --book_name test_books/animal_farm.epub --prompt "Please translate \`{text}\` to {language}"
```

---

### Zh Providers (`docs/zh/providers.md`)

# 提供方文件与额外模型

一次运行通过一个端点翻译。有两类步骤可以使用第二个端点：

- **分类。** 计划模式会问模型，EPUB 里哪几种块值得翻译。默认问的就是翻译模型。你可以用 `--classify-model` 另外指定一个。
- **查看页面图像的步骤。** 目前只有一个：在 [PDF 路线](features/pdf-to-epub.md)上，视觉模型可以纠正 docling 给页面各区域判定的角色。只有用 `--img-model` 指定了模型时才会运行。

两者都可以在命令行或提供方条目里指定。本页介绍提供方文件、这两个额外模型，以及哪个 key 发往哪里。

## 提供方文件

`--provider NAME` 从 JSON 文件而不是参数中读取路线。运行会读三个文件，同名时后面的优先：

1. `bbm_providers.example.json`，随 `make_book.py` 一起提供。只有某个名字在另外两个文件里都没有时，运行才会用它，而且每次都会说明，并给出所用的地址和 key 变量。其中的 `FILL-ME` 条目是模板，永远不会被使用。
2. `~/.bbm/providers.json`。
3. 运行目录下的 `bbm_providers.json`。

```json
{
  "providers": {
    "siliconflow": {
      "api_style": "openai",
      "base_url": "https://api.siliconflow.cn/v1",
      "default_models": ["Qwen/Qwen2.5-72B-Instruct"],
      "env_key": "BBM_SILICONFLOW_API_KEY"
    }
  }
}
```

```bash
bbook_maker \
  --book_name my_book.epub \
  --provider siliconflow \
  --use_context session
```

你自己传入的参数优先于条目。

### 字段

| 字段 | 必填 | 含义 |
|---|---|---|
| `api_style` | 是 | `openai`、`anthropic`、`gemini`、`qwen`、`groq`、`xai` 或 `litellm` |
| `base_url` | 否 | 地址；默认是该格式自己的地址 |
| `default_models` | 否 | 要用的模型；不传 `--model` 时必填 |
| `env_key` | 否 | 存放 key 的环境变量；不传 `--key` 时必填 |
| `prices` | 否 | 每百万 token 的价格，按模型分列：`{"<model>": {"input": …, "output": …, "cached_input": …}}`。本次运行的每个模型都有价格时，进度条显示已花的钱（`spent=$0.012`），而不是 token 数 |
| `currency` | 否 | 默认 `USD`；`EUR`、`GBP`、`CNY`、`JPY` 会带各自的符号打印 |
| `img_model` | 否 | 页面图像步骤所用的视觉模型。相当于 `--img-model` |
| `img_base_url` | 否 | `img_model` 不在本条目自己的地址上时，它所在的地址。仅限 OpenAI 兼容 |
| `img_env_key` | 否 | 存放 `img_model` 所在地址的 key 的变量 |
| `classify_model` | 否 | 分类用的模型。相当于 `--classify-model` |
| `classify_base_url` | 否 | `classify_model` 不在本条目自己的地址上时，它所在的地址。OpenAI 兼容地址，或 [Jev 兼容](#jev-与-jev-兼容分类器)分类器的 URL |
| `classify_env_key` | 否 | 存放 `classify_model` 所在地址的 key 的变量 |

花费金额是按每个请求报告的用量估算的；以厂商的账单为准。

### 随附的 `openai` 条目会开启图像步骤

示例文件的 `openai` 条目设置了 `"img_model": "gpt-6-luna"`。所以在 PDF 上用 `--to-epub` 加 `--provider openai`，默认会对每一页运行区域角色校正。即使你没有 `bbm_providers.json` 也一样，因为运行会退回使用示例文件。这一步每页约花 3,000 个提示 token：在它背后的那项研究中，12 页共用了 43,443 个提示 token 和 4,319 个补全 token（[用图像模型判定区域角色](evaluation/pdf-structure-llm-roles.md)）。不想要这一步，就传 `--img-model none`，或者把文件复制一份并删掉那一行。

`openai-jev` 条目就是 `openai` 条目加上 `"classify_model": "jev"` 和 `JEV_API_KEY`，所以 `--provider openai-jev` 用 gpt-6-luna 翻译，用 [Jev](#jev-与-jev-兼容分类器) 给 EPUB 的计划分类。`jev` 条目只有 Jev：它只分类、从不翻译，所以 `--provider jev` 会被拒绝，并提示改用 `--classify-model`。其他条目都没有写图像模型或分类模型。

## 两个额外模型

### 各模型从哪里来

| 步骤 | 首先 | 其次 | 最后 |
|---|---|---|---|
| 图像（`--img-model`） | 参数 | 条目的 `img_model` | **关闭** |
| 分类（`--classify-model`） | 参数 | 条目的 `classify_model` | 本次运行自己的模型 |

图像步骤绝不会退回使用翻译模型。只有通过参数或条目为它指定了模型才会运行，所以没有哪个默认设置需要能读图的模型。`--img-model none` 在单次运行中关闭条目里的图像模型。

`--plan-classify-model` 是 `--classify-model` 的旧名。它仍然可用，只是不出现在 `--help` 里。两个都写时，`--classify-model` 优先。

### 各模型在哪里被询问

- **没有 base URL 时**，模型在本次运行自己的端点上被询问，用本次运行的格式和 key。
    - 图像模型要求该端点是 OpenAI 形式。其他任何格式下，运行在开始前就会停下：`--img-model needs an OpenAI-compatible endpoint; … resolves to the … format.`
    - 分类模型在任何能对话的大模型路线上都能用，anthropic 和 codex 路线也包括在内。
    - 在[翻译服务](machine-args.md)路线上，没有可以共用端点的模型。这时不带 base 指定的分类模型，会在它的 ID 所暗示的主机上被询问：`--classify-model gpt-6-luna` 发往 OpenAI。
- **有 `--img-base-url` 或 `--classify-base-url` 时**，模型在那里被询问。该地址必须说 OpenAI 形式，对分类器来说也可以是 [Jev 协议](#jev-与-jev-兼容分类器)；其他任何情况都会在花钱之前被拒绝。
- 只写 base URL 而不写对应模型，运行会停下：`--img-base-url names where --img-model is served, and no --img-model was given.`（`--classify-base-url` 同理）。

指定的分类模型总有自己的客户端，即使在本次运行的地址上也是如此，所以它的 token 与翻译的 token 分开计数。

图像步骤运行之前，会先检查一次模型能否读图。不能的话，运行会说 `… did not read the probe image (…); image steps are skipped this run.`，然后跳过这一步继续。

### 哪个 key 发往哪里

key 与地址绑定，永远不会发往它不该去的主机。对图像模型和分类模型，key 按以下顺序查找：

1. `--img-key` 或 `--classify-key`。
2. 本次运行的 key，但仅当模型在本次运行自己的地址上被询问时。本次运行的地址是应用 `--api_base` 之后的那个地址。
3. 条目的 `img_env_key` 或 `classify_env_key`，但仅限该条目为模型指定的地址：它的 `img_base_url` 或 `classify_base_url`，否则是条目的 `base_url`。`--api_base` 把运行移到别处时，不会把条目的 key 一起带过去。
4. 该地址所属格式读取的变量，与本次运行相同（见 [API key](llm-args.md#api-key)）。

以上都没有 key 时，运行会停下，并说出应该传哪个参数。

`--extra_body` 和 `--extra_headers` 只有在图像模型或分类模型在本次运行自己的地址上被询问时才会带给它们。请求头常常带着网关的凭据，所以绝不会发往其他主机。

### 示例

在本地模型上翻译，让托管模型给计划分类并读取页面图像：

```json
{
  "providers": {
    "local-with-helpers": {
      "api_style": "openai",
      "base_url": "http://localhost:11434/v1",
      "default_models": ["qwen3:8b"],
      "classify_model": "gpt-6-luna",
      "classify_base_url": "https://api.openai.com/v1",
      "classify_env_key": "OPENAI_API_KEY",
      "img_model": "gpt-6-luna",
      "img_base_url": "https://api.openai.com/v1",
      "img_env_key": "OPENAI_API_KEY"
    }
  }
}
```

用参数写是这样：

```bash
bbook_maker \
  --book_name my_book.epub \
  --api_base http://localhost:11434/v1 \
  --model qwen3:8b \
  --classify-model gpt-6-luna \
  --classify-base-url https://api.openai.com/v1 \
  --classify-key "$OPENAI_API_KEY" \
  --use_context session
```

本地服务器不需要 key，所以这里本次运行的 key 是空的；分类器的 key 必须指定。

## Jev 与 Jev 兼容分类器

Jev 是 TypeSafe 的分类器：一个专门回答类型化问题、而不是写文字的模型。计划模式对每种块提的问题——翻译还是保留——正是这类问题，Jev 能在一次廉价的往返中回答一整页。它什么都不翻译，所以只能充当分类模型。TypeSafe 前面的网关（Vercel 的，或把 TypeSafe 设为自定义提供方的 Cloudflare AI Gateway）说同一套协议。

### 命令

| 分类器 | 参数 | key |
|---|---|---|
| TypeSafe 的 Jev | `--classify-model jev` | `JEV_API_KEY` 或 `TYPESAFE_API_KEY`，只发往 `api.typesafe.ai` |
| 经网关访问 Jev | `--classify-model typesafe-ai/jev --classify-base-url https://ai-gateway.vercel.sh/typesafe --classify-key "$GATEWAY_KEY"` | 用 `--classify-key` 指定 |
| 经 Cloudflare AI Gateway 访问 Jev | `--classify-model jev --classify-base-url https://gateway.ai.cloudflare.com/v1/$ACCOUNT_ID/$GATEWAY_ID/custom-typesafe --classify-key "$JEV_API_KEY"` | 用 `--classify-key` 指定；网关开启认证时用 `CF_AIG_TOKEN`，此时网关也可以代存 key |

例如，用 gpt-6-luna 翻译、用 Jev 分类：

```bash
bbook_maker \
  --book_name my_book.epub \
  --model gpt-6-luna \
  --classify-model jev
```

### 规则

- **key 只为它自己的主机从环境变量读取。** `JEV_API_KEY` 和 `TYPESAFE_API_KEY` 只发往 typesafe.ai 地址。其他任何地方，包括网关，都要用 `--classify-key` 或条目的 `classify_env_key` 指定 key。
- **Cloudflare 网关的 token 只为 Cloudflare 读取。** `CF_AIG_TOKEN` 只以 `cf-aig-authorization` 发往 gateway.ai.cloudflare.com。设置了 token 又没有指定 key 时，不发送 key：由网关提供它保存的 key。在网关上，TypeSafe 是一个 base URL 为 `https://api.typesafe.ai` 的[自定义提供方](https://developers.cloudflare.com/ai-gateway/configuration/custom-providers/)。
- **已经以 `/systemone` 结尾的 base URL 原样使用。** 其他 Jev base 会在 `/v1` 之后加上 `/systemone`。
- 只写 `jev` 时，请求的是 TypeSafe 当前的模型 `jev-latest`。

### 闸门：没把握的跳过按翻译处理

Jev 的每个回答都带一个概率。低于闸门的 `skip` 记为 `translate`，所以不会因为 Jev 没把握的跳过而丢失内容。`translate` 不论概率多少都会采纳。闸门是所选回答的概率 0.95，这是在 45 本 EPUB 的 662 个计划签名上对照 gpt-5.6-luna 测出来的。取这个值时，Jev 的跳过约有十分之九变成 `translate`，Jev 仍然跳过的都是附属内容：版权行、行号、注释标记、索引页码。所以在这个语料上，Jev 比全部翻译省不了多少；见[用 Jev 做计划分类器](evaluation/plan-classifier-jev.md)。计划文件会在每个回退行上注明：`unnamed (jev verdict skip at confidence 0.61, below the gate: translate)`。`--classify-min-confidence P`（0 到 1）为单次运行调整闸门；`BBM_JEV_MIN_CONFIDENCE` 不用参数也能做到同样的事，两者都有时以参数为准。值越低，保留的 Jev 跳过越多：在测过的语料上，低于 0.9 的跳过与参照结果一致的比例只有 7–55%，所以风险由你承担。

### 写在提供方条目里

如果你想让 OpenAI 翻译、Jev 分类，随附的 `openai-jev` 条目已经做到了：`--provider openai-jev`。其他任何组合，请自己写条目。

`classify_model`、`classify_base_url` 和 `classify_env_key` 指定 Jev 分类器的方式与参数相同：

```json
{
  "providers": {
    "openai-with-jev": {
      "api_style": "openai",
      "default_models": ["gpt-6-luna"],
      "env_key": "OPENAI_API_KEY",
      "classify_model": "typesafe-ai/jev",
      "classify_base_url": "https://ai-gateway.vercel.sh/typesafe",
      "classify_env_key": "JEV_API_KEY"
    }
  }
}
```

为自己的网关地址写明 `JEV_API_KEY` 的条目，就是为那个地址指定了这个 key，会被遵从。没有这样的条目，Jev 的变量永远不会发往网关。

每个 Jev 请求把页面的候选行发送一次，每个签名附一个简短的问题。见[用 Jev 做计划分类器](evaluation/plan-classifier-jev.md)。

## 运行会打印什么

`--plan-dry-run` 不需要 key，就能显示每个模型会在哪里被询问：

```text
Classifier: gpt-6-luna at the openai endpoint's default host (cli)
Image model: off
```

括号里是这个选择的来源：`cli`、`provider` 或 `run`。在 `--plan-classify all` 或 `agent` 下，这一行是 `Classifier: none (--plan-classify agent asks nothing)`。

运行结束时，拥有自己客户端的模型会在翻译用量那一行下面打印自己的用量：

```text
Classifier (gpt-6-luna at the endpoint's default host): tokens: in 7.6k, out 1.8k, cached 0 (3 requests)
```

图像模型的那一行是 `Image model (<model> at <address>): …`，在提取之后打印。

以下警告表示某个参数在本次运行中不起作用：

- **`--img-model, --img-base-url and --img-key choose the vision model for the steps that look at a page image, and only the PDF route (--to-epub on a PDF) has one; …`** 你指定了图像模型，但这本书不是走 `--to-epub` 路线的 PDF。
- **`--classify-model names a classifier, and --plan-classify all translates the whole partition without classifying anything; it is ignored this run.`** `--plan-classify agent` 也一样：智能体模式把每一行都留给你的智能体，不问任何模型。
- **`Nothing on this route classifies yet, so --classify-model is ignored on a … book.`** 目前只有 EPUB 有分类步骤。

---

### Zh Session Grouping Eval (`docs/zh/session_grouping_eval.md`)

这份报告已拆分为[每次请求的单元数与 token 数](evaluation/grouping-batch-size.md)和[会话压缩预算](evaluation/session-compact-budget.md)两页。

---

### En Evaluation Grouping Batch Size (`docs/en/evaluation/grouping-batch-size.md`)

# Units and tokens per request: `--max-batch-units`, `--accumulated_num`

## Abstract

Plan mode puts several units (paragraphs, verse lines, cells) into one request. Two limits bound a request: a unit cap (`--max-batch-units`) and a token budget (`--accumulated_num`). Two paid studies asked where requests start to go wrong. A 210-request study across five endpoints found every request-level fault in its 50-unit bucket. A 923-request sweep found the first content fault at 64 effective units per request, on literary prose only, and no fault anywhere across budgets of 1600 to 4800 tokens. The shipped defaults sit well below both: 16 units and a 1200 to 1600 token budget, halved on endpoints that do not verify a strict JSON schema. **These defaults are an owner-set safety margin, not a measured optimum.** The measurements say larger requests are cheaper per translated word; the owner chose margin over that saving.

## Setup

**The degradation study (September 4, 2026).** 210 requests, 5 arms, request sizes of 500, 1000, 2000, 4000 and 8000 characters, up to 50 units per request. The batches were byte-identical across arms at each size, built by the branch's own planner from childrens-literature, moby-dick, jlreq-in-english and mahabharata. Arms: `gpt-4o-mini` and `gpt-5.6-luna` on OpenAI's endpoint, luna and `claude-sonnet-5` through a reseller, and `deepseek-v4-flash` through OpenRouter. Faults were judged by fixed rules: lazy tail compression, truncation, digit preservation.

**The fault-emergence sweep (September 5, 2026).** 76 valid cells and 923 requests on the Codex route: 46 specified cells plus a 30-cell extension to 64, 96 and 128 units once the route's own halving was found to cap the grid at 24 effective units. Four epub-samples books: childrens-literature (literary prose), moby-dick-mo (a long novel), epub30-spec (short technical units) and wasteland (verse). Two models: `gpt-5.4-mini` and the Codex default. Every cell was read back from the produced EPUB. A content fault is a translation in the wrong slot, a missing one, or marker residue in the text.

**A weak-model rerun** of both axes on `gpt-4o-mini` and `deepseek-chat`. It is known from the September 2026 grouping report and its two figures below; its per-cell table was not kept.

## Results

The degradation study, largest clean request size per arm:

| arm | largest clean rung | notes after correction |
|---|---|---|
| off-mini (official 4o-mini) | 1000 | real faults at 2000+ (below) |
| off-luna (official luna) | **8000, all clean** | its rule-4 trips are zh-numeral false positives |
| rtr-luna (nianhuaapi luna) | **8000, all clean** | only delta vs official: 2.4–6× latency |
| rtr-sonnet (nianhuaapi claude-sonnet-5) | none | 0/30 schema-valid JSON — wire format, not translation |
| or-deepseek (OpenRouter deepseek-v4-flash) | ~4000 | + source-echo fault (below), 6.6× completion-token burn, slowest |

- Lazy tail compression never tripped (worst 88% against a 70% threshold). Zero truncation in 210 requests. 36 of 39 unmatched digit runs were correct Chinese numerals.
- **Every request-level fault outside rtr-sonnet was in the 50-unit bucket.** Unit count, not character budget, was the binding axis.
- One silent tail shift: `gpt-4o-mini`, 8000 characters, 50 alternating quote/citation units. The ids came back in order but the translations of roughly slots 44 to 49 were shifted by one. The id echo cannot catch this shape.
- The id echo caught what it was built for: a 50-unit mahabharata batch came back with ids 0 to 21 and 28 paragraphs missing, reported as a success by the endpoint.

The fault-emergence sweep:

- **First content fault at 64 effective units per request**, childrens-literature prose only, 9.4% of units. moby, spec and wasteland were clean through 64. The Codex default model's 7.8% at 24 units was a single misaligned request, clean at 32, 48 and 64.
- **Content per request, not unit count, carries the risk.** 4705 source tokens in 48 units were clean; 3563 tokens in 64 units faulted; 64 units of short lines (600 to 1100 tokens) were clean.
- **The token budget was fault-free across 1600 to 4800.** What rises past about 2000 is retry cost: +150% request overhead at 2400 with large unit counts.
- Misalignment is U-shaped: two-unit batches misaligned on 3 of 4 books (harmless; the ladder lands on single units), the middle was mostly silent, and at 64 the retry misaligned again.
- The sweep found a real corruption path: a one-slot shift that survived reconciliation put three literal `⟦spanN⟧` tokens into visible prose. It is fixed: such a batch now goes down the halving ladder instead of being written.



*Figure: fault rate and retry cost against units and budget, 76 cells, 923 requests. The lines show the defaults of the time (32 units, a 1600 to 2000 budget), before the September 7 ruling.*

The weak-model rerun: a raised unit cap costs retries first and faults later, so the cap is the knob to lower and the budget is not.



DeepSeek compressed aggressively at every cap and budget (median zh/en character ratio 0.25 to 0.31, against gpt-4o-mini's 0.31 to 0.35). Short translations on that model are the model, not a grouping fault.



*Figures: weak-model rerun, from the September 2026 grouping report; its per-cell table was not kept.*

## Decision

On September 5 the sweep's lead set the cap to 32 (half the measured emergence) and kept a 2000-token ceiling on retry-cost grounds. On September 7 the owner lowered every grouping default:

| constant | old | new |
|---|---|---|
| `GENERAL_GROUP_MAX_UNITS` (plan.py) | 32 | **16** (quarter of the 64-unit measured fault onset) |
| `SUBSTRICT_GROUP_MAX_UNITS` | 16 (derived `//2`) | **8** (still derived) |
| `substrict_batch_cap` (chatgptapi) | 16 | **8** |
| `SESSION_BUDGET_FLOOR` / `CEILING` | 2400 / 3200 | **1200 / 1600** |
| `SUBSTRICT_BUDGET_FLOOR` | derived 1200 | **typed 800** (deliberately NOT `floor//2`=600 — no double margin) |

The owner's reasons, as recorded: schema support is an endpoint property, not evidence that the model follows instructions; strong models are not necessarily better at format compliance; and a fault means the model cannot hold the request together. A shifted slot is a wrong book, not an expensive one. So a safety margin outranks the measured per-content savings (15.5 input-equivalents per content token at a budget of 800 against 12.3 at 1600).

Why the halving on weaker endpoints: both content regressions the September 5 JSON-object eval found were large batches, and an endpoint without a strict-schema verdict cannot be told apart from one where miscounted replies live. So on such an endpoint the unit cap is 8 and the per-request budget is 800.

What would change it: a rerun of the sweep that shows faults below 16 on some model (lower it), or the owner deciding the margin is worth less than the savings (raise it). Until then, lower `--max-batch-units` when a run prints `misaligned batches this run`; raise it only for cost, at your own risk, and never past 48.

## Limits

- **The sweep's "weak" model was `gpt-5.4-mini`, which is not a weak-model proxy.** It is unusually strong at format compliance. The sweep's finding that "the weak model held the reply format better than the strong one" (2 recoveries in 9 childrens cells against 5 in 6) is therefore unproven for genuinely weak models. The `gpt-4o-mini` and DeepSeek rerun is the weak-model evidence, and its per-cell table was not kept.
- Only four books, and only childrens-literature reached prose at 128 units; moby and spec spent their test slice on front matter.
- Nothing between 16 and 64 units was measured on an on-device model.
- The token budget was measured fault-free only from 1600 up, and only at the old 32-unit cap. The shipped 800 to 1600 sits below the measured range on purpose.

---

### En Evaluation Pdf Ocr Engines (`docs/en/evaluation/pdf-ocr-engines.md`)

# OCR engines on scanned pages: `--ocr-engine` rapidocr, ocrmac, easyocr, tesseract

Since this measurement, the `pdf` extra installs onnxruntime, and ocrmac on macOS, so rapidocr and ocrmac download nothing on a fresh install; the install notes below describe the install as it was measured.

## Abstract

`--ocr-engine` picks the engine docling runs on pages with no text layer. The four engines docling can run locally were measured on the same 20 scanned pages (14 English, 6 Chinese), with the same scorer and the same language asked of each, all on the CPU. On English, tesseract had the lowest error (mean CER 0.104) and ocrmac came next (0.136); rapidocr (0.169) and easyocr (0.267) trailed. On simplified Chinese, rapidocr was best (mean Han CER 0.054 over five pages, against ocrmac 0.097 and tesseract 0.116); on the one traditional page ocrmac and rapidocr were close (0.043 and 0.060). easyocr was last almost everywhere, took three times as long and up to 6.6 GB of memory, and dropped text on Chinese pages. ocrmac, Apple's own engine, was the fastest (median 6 s per two pages) and needs nothing downloaded.

The default stays `auto`. This page is the evidence behind [Which OCR engine](../features/pdf-ocr-engines.md).

## Setup

- **Route:** the PDF route's own extraction, run through the staged harness (`tools/pdf_to_book.py extract`), docling 2.129.0, `--pdf-ocr --device cpu --pages 1-2`, default OCR mode (a page's embedded layer is kept where docling keeps it). One conversion at a time, each under a memory cap, `HF_HUB_OFFLINE=1`.
- **Engines:** rapidocr 3.9.2 on onnxruntime 1.23.2 (PP-OCRv6 small models, shipped inside the rapidocr package); ocrmac 1.0.1 (Apple Vision, macOS 26); easyocr 1.7.2 on PyTorch 2.9.0 (CPU); tesseract 5.5.3 (Homebrew) with the `tessdata_fast` language files (`eng`, `chi_sim`, `chi_tra`, `osd`).
- **Languages:** the same `iso:` tag for every engine, which docling maps onto each engine's own code: `iso:en` for the English pages, `iso:zh-Hans` for simplified Chinese, `iso:zh-Hant` for traditional. The run's own line confirmed the engine and the languages in every cell.
- **Device:** `--device cpu` on every cell, so the layout and table models ran on the CPU for all four arms. rapidocr (onnxruntime), easyocr (PyTorch) and tesseract ran on the CPU; ocrmac runs inside Apple's Vision framework, which chooses its own hardware.
- **Machine:** Apple silicon, 16 GB, Python 3.12.12.
- **Pages:** two-page cuts; the scored pages are the ones with ground truth:

| fixture | scored page(s) | what it is | language flag |
|---|---|---|---|
| scan_cia_typewriter | 1, 2 | degraded typewriter scan (CCITT) | `iso:en` |
| scan_cia_typewriter_mid | 2 | typewriter scan, mid-document | `iso:en` |
| scan_forward1963 | 1 | 1963 magazine, two columns, footnotes | `iso:en` |
| scan_en_degraded_1_mid | 1 | colour pamphlet with a table | `iso:en` |
| scan_en_degraded_2_mid | 1 | bitonal old novel page | `iso:en` |
| scan_zh_hans_qm_mid | 1 | Internet Archive book scan, simplified Chinese, invisible OCR layer, JBIG2 masks | `iso:zh-Hans` |
| zh_hant_scan_2_mid | 2 | traditional Chinese, horizontal, with a table | `iso:zh-Hant` |
| syn_arxiv_1706_03762_{clean,deg} | 1, 2 | born-digital paper rendered as a scan | `iso:en` |
| syn_wps_eula_{clean,deg} | 1, 2 | WPS licence document rendered as a scan | `iso:en` |
| syn_web_zh_report_chrome_{clean,deg} | 1, 2 | Chinese web report rendered as a scan | `iso:zh-Hans` |

Synthetic scans: 200 dpi; clean is a lossless PNG, degraded is grayscale, rotated 1.5°, blurred (r=0.8) and JPEG q40. Their ground truth is exact (the source page's text layer). The real scans' ground truth was typed from the page image. The Chinese book scan carries JBIG2 masks, so its page images came from pypdfium2 on every arm (the run printed the JBIG2 line).

- **Scorer:** Normalisation: Markdown syntax stripped (image links, comments, `#`, table pipes and rules, `**`, escapes, list bullets), HTML unescaped, NFKC, every quote variant mapped to `"`, every hyphen and dash removed, whitespace collapsed (removed entirely for Chinese). **CER** is order-sensitive: a paragraph read in another order counts as errors. **Han CER** is CER over the Han characters only. **line_err** ignores order: the length-weighted mean of (1 − partial match) over ground-truth lines, so it measures recognition and detection alone. The heading the route adds above an extraction that does not open with one (`# <file name>`) was removed before scoring.

Commands, one per cell (`$E` is the evaluation directory, `$ENGINE` one of the four, `$LANG` from the table above):

```bash
HF_HUB_OFFLINE=1 TESSDATA_PREFIX=$E/tessdata \
  python tools/pdf_to_book.py extract $E/fixtures/p12/scan_cia_typewriter.pdf \
  --output $E/runs/K/$ENGINE_cia/bundle \
  --pdf-ocr --ocr-engine $ENGINE --ocr-lang iso:en --device cpu --pages 1-2
```

and the same with `--ocr-engine auto` on one fixture per script, to see what `auto` picks.

## Results

CER per page, lower is better; Chinese pages also give the Han-only CER:

| page | rapidocr | ocrmac | easyocr | tesseract | auto |
|---|---|---|---|---|---|
| cia p1 | 0.158 | 0.119 | 0.073 | 0.120 | 0.119 |
| cia p2 | 0.272 | 0.109 | 0.148 | 0.110 | 0.109 |
| ciamid p2 | 0.188 | 0.029 | 0.080 | 0.017 | - |
| fwd p1 | 0.251 | 0.231 | 0.260 | 0.212 | - |
| deg1 p1 | 0.355 | 0.360 | 0.965 | 0.046 | - |
| deg2 p1 | 0.543 | 0.491 | 0.602 | 0.408 | - |
| zh p1 | 0.112 (Han 0.009) | 0.210 (Han 0.056) | 0.390 (Han 0.235) | 0.201 (Han 0.036) | 0.210 (Han 0.056) |
| hant2 p2 | 0.069 (Han 0.060) | 0.041 (Han 0.043) | 0.670 (Han 0.724) | 0.322 (Han 0.350) | 0.041 (Han 0.043) |
| synarxc p1 | 0.051 | 0.050 | 0.124 | 0.051 | - |
| synarxc p2 | 0.040 | 0.003 | 0.056 | 0.004 | - |
| synarxd p1 | 0.142 | 0.055 | 0.431 | 0.169 | - |
| synarxd p2 | 0.002 | 0.269 | 0.534 | 0.272 | - |
| syneulac p1 | 0.015 | 0.014 | 0.071 | 0.015 | - |
| syneulac p2 | 0.171 | 0.008 | 0.028 | 0.008 | - |
| syneulad p1 | 0.110 | 0.163 | 0.215 | 0.014 | - |
| syneulad p2 | 0.066 | 0.008 | 0.153 | 0.008 | - |
| synzhc p1 | 0.042 (Han 0.000) | 0.040 (Han 0.003) | 0.564 (Han 0.514) | 0.077 (Han 0.019) | - |
| synzhc p2 | 0.076 (Han 0.053) | 0.045 (Han 0.000) | 0.656 (Han 0.611) | 0.085 (Han 0.021) | - |
| synzhd p1 | 0.188 (Han 0.191) | 0.167 (Han 0.163) | 0.799 (Han 0.862) | 0.193 (Han 0.171) | - |
| synzhd p2 | 0.136 (Han 0.018) | 0.312 (Han 0.262) | 0.800 (Han 0.848) | 0.390 (Han 0.333) | - |

line_err per page (order ignored):

| page | rapidocr | ocrmac | easyocr | tesseract | auto |
|---|---|---|---|---|---|
| cia p1 | 0.085 | 0.001 | 0.045 | 0.003 | 0.001 |
| cia p2 | 0.181 | 0.076 | 0.110 | 0.078 | 0.076 |
| ciamid p2 | 0.093 | 0.007 | 0.036 | 0.000 | - |
| fwd p1 | 0.009 | 0.020 | 0.054 | 0.007 | - |
| deg1 p1 | 0.010 | 0.043 | 0.559 | 0.033 | - |
| deg2 p1 | 0.054 | 0.118 | 0.291 | 0.019 | - |
| zh p1 | 0.036 | 0.144 | 0.142 | 0.119 | 0.144 |
| hant2 p2 | 0.050 | 0.037 | 0.536 | 0.272 | 0.037 |
| synarxc p1 | 0.034 | 0.033 | 0.057 | 0.033 | - |
| synarxc p2 | 0.025 | 0.002 | 0.034 | 0.004 | - |
| synarxd p1 | 0.035 | 0.034 | 0.265 | 0.082 | - |
| synarxd p2 | 0.001 | 0.003 | 0.316 | 0.007 | - |
| syneulac p1 | 0.001 | 0.000 | 0.047 | 0.001 | - |
| syneulac p2 | 0.109 | 0.002 | 0.018 | 0.002 | - |
| syneulad p1 | 0.001 | 0.041 | 0.117 | 0.000 | - |
| syneulad p2 | 0.047 | 0.002 | 0.085 | 0.002 | - |
| synzhc p1 | 0.031 | 0.030 | 0.482 | 0.062 | - |
| synzhc p2 | 0.059 | 0.035 | 0.563 | 0.067 | - |
| synzhd p1 | 0.089 | 0.043 | 0.693 | 0.074 | - |
| synzhd p2 | 0.047 | 0.044 | 0.719 | 0.117 | - |

Means per class, CER (Han CER for Chinese) / line_err:

| class | n | rapidocr | ocrmac | easyocr | tesseract |
|---|---|---|---|---|---|
| typewriter scan, English (cia p1, p2, ciamid p2) | 3 | 0.206 / 0.120 | 0.086 / 0.028 | 0.100 / 0.064 | 0.082 / 0.027 |
| magazine and old print, English (fwd, deg1, deg2) | 3 | 0.383 / 0.024 | 0.361 / 0.060 | 0.609 / 0.301 | 0.222 / 0.020 |
| synthetic scan, English, clean | 4 | 0.069 / 0.042 | 0.019 / 0.009 | 0.070 / 0.039 | 0.019 / 0.010 |
| synthetic scan, English, degraded | 4 | 0.080 / 0.021 | 0.124 / 0.020 | 0.333 / 0.196 | 0.116 / 0.023 |
| book scan, simplified Chinese (zh, JBIG2) | 1 | 0.009 / 0.036 | 0.056 / 0.144 | 0.235 / 0.142 | 0.036 / 0.119 |
| scan, traditional Chinese, horizontal (hant2) | 1 | 0.060 / 0.050 | 0.043 / 0.037 | 0.724 / 0.536 | 0.350 / 0.272 |
| synthetic scan, simplified Chinese, clean | 2 | 0.027 / 0.045 | 0.001 / 0.033 | 0.562 / 0.523 | 0.020 / 0.065 |
| synthetic scan, simplified Chinese, degraded | 2 | 0.105 / 0.068 | 0.213 / 0.044 | 0.855 / 0.706 | 0.252 / 0.096 |

Per engine:

| engine | pages | mean CER, English (n) | mean Han CER, Chinese (n) | mean line_err, English | mean line_err, Chinese | median extraction s per two-page cell | peak memory MB (max) |
|---|---|---|---|---|---|---|---|
| rapidocr | 20 | 0.169 (14) | 0.055 (6) | 0.049 | 0.052 | 12 | 2248 |
| ocrmac | 20 | 0.136 (14) | 0.088 (6) | 0.027 | 0.056 | 6 | 1661 |
| easyocr | 20 | 0.267 (14) | 0.632 (6) | 0.145 | 0.523 | 37 | 6596 |
| tesseract | 20 | 0.104 (14) | 0.155 (6) | 0.019 | 0.119 | 10 | 1902 |
| auto | 4 | 0.114 (2) | 0.050 (2) | 0.039 | 0.090 | 12 | 1977 |

Extraction time is the run's own `PDF extracted: 2 pages, …, N s.` line: model loading plus layout, tables and OCR for two pages. Per cell: rapidocr 9–19 s, ocrmac 4–11 s, tesseract 7–21 s, easyocr 32–90 s (the 90 s cell includes its first-use model download).

**What `auto` picked:** ocrmac in 3 of 3 cells (`OCR engine: ocrmac (docling's choice on this install), …`), because ocrmac was installed; its text was identical to the ocrmac cells.

**Reading order, not recognition, explains several CER gaps.** synarxd p2: ocrmac and tesseract CER 0.269 and 0.272 against rapidocr's 0.002, but line_err 0.003 and 0.007: the text is right, a block was placed elsewhere. deg1: rapidocr and ocrmac CER 0.355 and 0.360 with line_err 0.010 and 0.043. For a book, a paragraph in the wrong place is a real defect, so CER is the primary score; line_err says whether the words themselves were read.

**easyocr dropped text.** On the Chinese synthetic pages it returned 108 to 370 of 753 Han characters; on deg1 it returned 133 characters of 3,518. It is also the only engine over 4 GB of memory: the simplified Chinese cell passed 6,000 MB and was killed by the cap, and the retry at an 8,000 MB cap peaked at 6,596 MB.

**Install and first-use download**, measured on this machine:

| engine | installed beyond the `pdf` extra | downloaded on first use | runs off-line afterwards |
|---|---|---|---|
| rapidocr | nothing: rapidocr 3.9.2 (31 MB) is in the extra, its onnxruntime models are inside the package; **onnxruntime (62 MB) is not in the extra** | nothing with onnxruntime (no file appeared during the sweep, for `iso:en`, `iso:zh-Hans` or `iso:zh-Hant`); on PyTorch (what `auto` falls back to without onnxruntime): 9.8 + 0.6 + 20.3 MB of models from modelscope.cn, observed once | yes |
| ocrmac | ocrmac 1.0.1 and pyobjc (28 MB), macOS only | nothing: Apple's Vision framework is part of macOS | yes |
| easyocr | easyocr 1.7.2 (15 MB) plus opencv-python-headless (98 MB), scikit-image (28 MB), networkx (7 MB) and smaller ones: about 150 MB | the detector `craft_mlt_25k.pth` (83 MB) always, then one recognizer per script: `english_g2.pth` 15 MB, `latin_g2.pth` 15 MB, `zh_sim_g2.pth` 22 MB, `chinese.pth` (traditional) 226 MB, downloaded during this sweep. From GitHub, by easyocr itself: `HF_HUB_OFFLINE` does not stop it | yes, from `~/.EasyOCR/model` |
| tesseract | the tesseract program (Homebrew: 25 MB plus leptonica 7 MB and about 35 libraries it depends on) | nothing: language files are installed by hand, `tessdata_fast` sizes `eng` 4.1 MB, `chi_sim` 2.5 MB, `chi_tra` 2.4 MB, `osd` 10.6 MB | yes |

## Decision

- The default stays `auto`: docling takes the first installed of ocrmac, rapidocr and easyocr, and names the one it used. `--ocr-engine` names one explicitly; an engine that is not installed, or ocrmac off macOS, is refused before any page is read, with the line that installs it.
- The recommendations on [Which OCR engine](../features/pdf-ocr-engines.md) follow the class table: tesseract or ocrmac for English (tesseract ahead on old print), rapidocr for simplified Chinese, ocrmac or rapidocr for traditional Chinese, easyocr only when nothing else reads the script.
- The `pdf` extra installs ocrmac on macOS, so `auto` picks it with nothing to download, and installs onnxruntime, so rapidocr runs on its bundled models instead of downloading PyTorch ones.

## Limits

- 20 pages, one to four per class; the real Chinese rows are one page each. Differences under about 0.02 CER between two engines on one class are within what a different page would change, so they are called ties. The engines are deterministic: rapidocr reproduced the earlier study's CIA numbers (0.158, 0.272) exactly, and `auto` reproduced the ocrmac cells exactly.
- Japanese, Korean and other scripts were not measured. Vertical Chinese was not measured here.
- One run per cell; no GPU. On a GPU, easyocr's time would drop; its memory would not.
- rapidocr was measured on onnxruntime. On PyTorch (the `auto` fallback of an install without onnxruntime) it was not scored: the one check stopped after the downloads.
- tesseract needs its `configs` folder beside the language files: with `TESSDATA_PREFIX` pointing at a folder of `.traineddata` files alone, every tesseract cell failed (`KeyError: 'text'`, tesseract printing `read_params_file: Can't open tsv`). Linking Homebrew's `configs` and `tessconfigs` folders into it fixed that; the numbers above are from the rerun.
- Default OCR mode: on the simplified Chinese scan, whose invisible OCR layer docling reads alongside, the engine's text is mixed with the layer's. `--ocr-replace-layer` was not measured here.
- Ground truth for the real scans was typed by one person.

---

### En Evaluation Pdf Structure Llm Roles (`docs/en/evaluation/pdf-structure-llm-roles.md`)

# Region roles with an image model: `--img-model`

## Abstract

docling cuts each page into regions (paragraph, heading, footnote, code, table, picture and so on) and puts them in reading order. When a region has the wrong role or the wrong order, the book shows it: a code listing printed as footnotes, section 2 before section 1, a figure missing above its caption. The study catalogued docling's faults on 85 pages, then asked a vision model (gpt-5.6-luna) to relabel the regions it could see. The model fixed most wrong labels on born-digital pages. It could not fix what only shows across pages (running headers), and it could not rebuild a page docling had shattered into fragments. Those need rules based on page geometry, run before any model.

The role pass is built: `--img-model` on the [PDF route](../features/pdf-to-epub.md#correcting-region-roles-with-a-vision-model). The geometry repairs the study calls for are not built yet, so running headers and shattered pages still come through as docling made them.

## Setup

- docling 2.129.0 with the text layer only (no OCR), on 43 slices of one or two pages: 85 pages, of which 51 are arXiv, 16 other papers, 4 scans and 14 web or Word pages.
- **Catalogue:** every page rendered with its regions drawn and tagged, and each fault recorded by hand with a class and a severity (book-breaking, visible, minor).
- **Role pass:** 12 pages carrying at least one wrong-label or furniture fault. The model saw the tagged overlay and a list of region ids with their current label and first 80 characters, and answered one label per id, or `abstain`. A script scored the answers against the catalogue; the model did not judge itself.
- **Whole-page probe:** 3 pages with a missing region. The model saw only the clean page and returned regions with boxes; they were matched to docling's at IoU 0.5 or more.

## Results

**The catalogue: 123 faults, 13 book-breaking.** Copied from the record:

| fault_class | arXiv (26 sl/51 pp) | paper (8 sl/16 pp) | scan (2 sl/4 pp) | web (7 sl/14 pp) | total | book_breaking | visible | minor |
|---|---|---|---|---|---|---|---|---|
| wrong_label | 8 | 11 | 5 | 7 | 31 | 1 | 26 | 4 |
| paragraph_merge_split | 11 | 5 | 5 | 9 | 30 | 8 | 13 | 9 |
| column_order | 6 | 2 | 1 | 3 | 12 | 2 | 8 | 2 |
| heading_level | 0 | 6 | 0 | 5 | 11 | 0 | 1 | 10 |
| furniture_as_body | 2 | 1 | 3 | 4 | 10 | 0 | 8 | 2 |
| spurious_region | 7 | 2 | 0 | 0 | 9 | 0 | 3 | 6 |
| missing_region | 1 | 2 | 0 | 3 | 6 | 2 | 2 | 2 |
| caption_attachment | 2 | 2 | 0 | 2 | 6 | 0 | 4 | 2 |
| duplicate_asset | 1 | 0 | 0 | 3 | 4 | 0 | 0 | 4 |
| misplaced_asset | 1 | 0 | 0 | 1 | 2 | 0 | 2 | 0 |
| body_as_furniture | 0 | 1 | 0 | 1 | 2 | 0 | 1 | 1 |
| **all** | 39 | 32 | 14 | 38 | 123 | 13 | 68 | 42 |

Wrong labels are the most frequent class but break a book once. Shattered or wrongly merged paragraphs break it 8 times: an embedded OCR layer turned into 165 regions on one page, inline maths split into 49 one-glyph items, a code listing that lost every newline, and docling's cross-page merge gluing a paragraph to an author block.

**Missing text is not a usable trigger.** 0.00% of text-layer characters fell outside every region on 78 of 85 pages, and at most 1.23% on any page. A missing figure or equation has no text-layer characters to miss. The signs that do work are geometric: an orphan caption, a formula box holding only its number, a sliver picture where a table should be.

**The role pass, 12 pages:**

| page | regions | label faults | fixed | kept wrong label | changed to another wrong label | abstained | introduced errors | extra correct changes | prompt / completion (reasoning) tokens | latency s |
|---|---|---|---|---|---|---|---|---|---|---|
| arxiv_1906_00495v1_p34 p4 | 17 | 3 | 1 | 2 | 0 | 0 | 0 | 0 | 3442 / 239 (157) | 5.6 |
| arxiv_2009_12164v1_p12 p1 | 13 | 5 | 5 | 0 | 0 | 0 | 0 | 1 | 3346 / 199 (130) | 5.7 |
| arxiv_2010_03667v1_p12 p1 | 20 | 4 | 4 | 0 | 0 | 0 | 0 | 1 | 3431 / 351 (254) | 7.6 |
| latexbook_p12 p2 | 11 | 6 | 2 | 4 | 0 | 0 | 1 (13:text->key_value_region) | 1 | 3190 / 520 (460) | 9.0 |
| mixed_photo_code_1_p3031 p1 | 17 | 9 | 9 | 0 | 0 | 0 | 0 | 0 | 2958 / 165 (84) | 6.0 |
| mixed_photo_code_1_p3031 p2 | 9 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 2739 / 150 (100) | 5.3 |
| statement_p12 p1 | 24 | 4 | 1 | 2 | 1 | 0 | 1 (18:text->key_value_region) | 1 | 3555 / 625 (512) | 6.0 |
| statement_p12 p2 | 9 | 5 | 0 | 5 | 0 | 0 | 0 | 0 | 3145 / 143 (94) | 3.5 |
| web_en_chrome_1_p12 p1 | 21 | 15 | 15 | 0 | 0 | 0 | 0 | 1 | 3308 / 619 (512) | 10.6 |
| word_en_table_1_mid p1 | 16 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 3451 / 265 (186) | 6.9 |
| word_en_table_1_p12 p2 | 5 | 2 | 2 | 0 | 0 | 0 | 0 | 0 | 3018 / 29 (0) | 2.7 |
| zhqm_mid p1 | 164 | 11 | 0 | 9 | 2 | 0 | 0 | 0 | 7860 / 1014 (345) | 9.5 |
| **total (12 pages)** | | 66 | 40 | 23 | 3 | 0 | 2 | 5 | 43443 / 4319 | 78.4 |



*Catalogued label faults on the 12 pages from the table above, split by what the model did. 66 faults in all.*

- **Fixed 40 of 66.** Leaving out the bank-statement pages and the OCR scan, 39 of 46.
- **Running furniture: 0 of 8.** A bank statement's repeated header and regulatory block look like body text on a single page. Only their repetition across pages marks them.
- **A shattered OCR page: 0 of 11.** Relabeling fragments does not rebuild a page.
- **2 errors introduced**, both `text -> key_value_region`, low harm. 5 extra changes were correct (the document title). The model never abstained, not even on the 164-region page.
- **Cost:** 43,443 prompt and 4,319 completion tokens for 12 pages, 2.7 to 10.6 s per page.

**Whole-page probe, 3 pages:** the model found all 3 missing regions with the right label (a figure, an equation with its number, a census table). Its boxes matched docling's on 38 of 45 regions, with mean IoU 0.82 to 0.90, so a proposed box has to be snapped to the page's own glyph and image bounds before anything is cropped from it.

**Recognition, deferred.** Tables: docling's cell F1 0.888 against the model's 0.854 on 17 tables, where 7 ground truths equal docling's output by construction. docling's formula decoding ran 27 minutes on two pages without output and was killed.

## Decision

The plan that came out of this study:

1. Geometry repair runs first and is not a model's job: shattered lines and paragraphs, the cross-page merge, one-glyph slivers, code newlines, and running furniture found by repetition across pages.
2. Then a role pass over the cleaned region ids. This is the first model step, because it is the cheapest seam and the only measured win.
3. A whole-page proposal for missing regions, only on a page one of the three geometric triggers flags, with the box snapped and checked.
4. Reading order by a model is untested and stays a candidate. Table and formula recognition stay deferred.

A design consult (Codex, recorded) set the constraints for step 2:

- a thin image adapter on the OpenAI-style route, reusing the existing structured-output ladder;
- a role change is a typed replacement of the item, never a bare label write;
- furniture, tables, pictures and lists are out of the first pass;
- accepted changes apply atomically; a page with an unusual change rate is quarantined, not trimmed (changed after the first real runs, below);
- every decision is written to `decisions.json` for audit;
- off by default.

**What was built.** Step 2, the role pass, ahead of the geometry repairs. The model sees the page rendered with docling's regions tagged, and answers one label per eligible region (text, section_header, title, caption, footnote, code, or abstain) under a strict per-region schema. Eligible regions are body text, titles and headings that no caption or footnote refers to, outside table cells and formulas. Lists, formulas, tables, pictures and furniture are not asked about. Accepted answers replace the item with one of the right type, before the formula and heading steps run.

The first real runs, gpt-5.6-luna on pages 1-2 of four fixtures, copied from the record:

| fixture | asked | accepted (applied) | kept | rejected | quarantined pages | calls | prompt / completion tokens | seconds |
|---|---|---|---|---|---|---|---|---|
| arxiv_2010_03667v1 | 27 | 6 | 21 | 0 | 0 | 2 | 7001 / 368 | 9.9 |
| web_en_chrome_1 | 28 | 4 | 24 | 0 | 0 | 2 | 6930 / 365 | 8.8 |
| mixed_photo_code_1_p3031 | 15 | 1 (9 more quarantined) | 5 | 0 | 1 | 2 | 5538 / 177 | 6.2 |
| arxiv_1706_03762 (held-out) | 21 | 2 | 19 | 0 | 0 | 2 | 6645 / 322 | 7.8 |

The quarantine blocked the one book-breaking fix it met: on the code page the model was right on 9 of 10 answers, which crossed the 60% change threshold. So the owner dropped the quarantine. A page where more than 60% of the asked items change now keeps its changes and prints `Region roles: … on page N changed; read that page in source.md before translating.` Rerun without the quarantine, the eight listing lines export as code (eight separate code blocks, since adjacent code items are not merged yet). The held-out paper came out with 0 changed lines of Markdown.

**The image model is chosen explicitly.** The pass runs only on a model you name, with `--img-model` or a provider entry's `img_model`. The translating model is never used for images by fallback (owner ruling), so no default needs a model that reads images. The shipped `openai` provider entry names gpt-5.6-luna, so `--provider openai` turns the pass on; `--img-model none` turns it off. Before the pass, a one-image probe checks that the model can see a picture; if it cannot, docling's labels stand.

**On-device models:** about 3,000 prompt tokens per page is modest, but the 164-region page took 7,860, which is beyond an 8B-class model. Point the pass at a hosted vision model with `--img-base-url`, or leave it off.

## Limits

- The role pass is measured on 12 pages and the whole-page probe on 3. The class-to-fix table in the record is a direction, not a rate.
- The catalogue was made by one worker from overlays; heading levels on arXiv were not judged (a separate study covers them).
- Only one model (gpt-5.6-luna) was run. No on-device model was tried.
- The built pass was checked on pages 1-2 of four fixtures; label-only changes such as text to footnote change nothing in the export yet.
- No OCR engine ran in the catalogue; the two scans were read through their embedded layers.

---

### En Evaluation Plan Classifier Jev (`docs/en/evaluation/plan-classifier-jev.md`)

# Jev as the plan classifier: `--classify-model`, measured against gpt-5.6-luna

## Abstract

Plan mode asks a model, for each kind of block in an EPUB, whether it is worth translating. That is a labeling job, not a writing job, so a dedicated classifier could do it. Jev, TypeSafe's System One classifier, answers typed multiple-choice questions with a confidence. The same 31 signatures of the test book were classified by Jev and by gpt-5.6-luna. They agreed on 27. All 4 disagreements were Jev choosing `skip` where luna chose `translate`, each at low confidence. Jev used about twice luna's prompt tokens. A second round sent each page's shared text once: Jev's prompt tokens halved, and its agreement with luna moved no more than between two Jev runs of the same code. The same round found that a gate at 0.5 on Jev's probability could never fire on a two-option question; the gate was then measured on a 45-EPUB corpus and set to 0.95. Verdict: Jev works as a classify model; the default stays the translating model.

## Setup

- **Book:** `test_books/animal_farm.epub`, the repository's test book. Its plan has 31 tag signatures.
- **Run:** `--test --test_num 8 --quiet --language zh-hans`, translating with gpt-5.6-luna, under a 1500 MB memory cap.
- **Luna arm:** `--model gpt-5.6-luna --classify-model gpt-5.6-luna`. The endpoint verifies a strict JSON schema, so the signatures go in pages of several per request.
- **Jev arm:** `--model gpt-5.6-luna --classify-model typesafe-ai/jev --classify-base-url https://ai-gateway.vercel.sh/typesafe`. One Choice question per signature. In this first round, below 0.5 confidence in its top answer, Jev answered `unsure`.
- **Fixed-engine cell:** `--api_format google --classify-model gpt-5.6-luna`, to check that a translation-service route gets a plan from an LLM classifier.
- **Compared:** each signature's verdict between the two arms. Nobody judged which verdict was right.

## Results

Copied from the record:

| cell | classification | classifier usage line |
|---|---|---|
| luna | 31 signatures in 3 requests, 4 skips | `Classifier (gpt-5.6-luna at the endpoint's default host): tokens: in 7.6k, out 1.8k, cached 0 (3 requests)` |
| Jev | one 503 retry, then 31 verdicts, 8 skips | `Classifier (typesafe-ai/jev at https://ai-gateway.vercel.sh/typesafe): tokens: in 14.4k, out 1.1k, cached 0 (3 requests)` |

**27 of 31 agree.** All 4 disagreements are Jev choosing skip where luna chose translate, and all 4 were Jev's low-confidence calls (0.58–0.61):

| Signature | Sample text |
|---|---|
| `inline:span.underline` | www.ericseat.com |
| `inline:span.calibre_4` | www.ericseat.com |
| `inline:span.calibre_16` | "M" |
| `inline:span.calibre3` | George Orwell |

Where the two agree, Jev's confidence is mostly above 0.9. It used about twice luna's prompt tokens (14.4k vs 7.6k), since each signature is sent as its own question.

**Fixed engine with an LLM classifier.** Google Translate with gpt-5.6-luna as the classifier: `llm classification: 31 verdict(s), 4 skip(s)`, `Translation plan: … coverage 99.8%`, and the classifier's line `Classifier (gpt-5.6-luna …): tokens: in 7.6k, out 1.6k (3 requests)`. The translations sat beside their originals and matched them (George Orwell → 乔治·奥威尔).

A second Jev run, after the key-routing fixes, retried three 503s and ended with `llm classification: 31 verdict(s), 8 skip(s)` and `Translation plan: 20 documents, 202560 chars, coverage 99.7%`.

### Second round: lean requests and the gate

Each Jev question used to repeat the whole per-signature prompt. The lean mapping sends the page's shared text once as the state, and each question carries only its own instruction. A doubtful `skip` now falls back to `translate` instead of `unsure`. Same book, same flags. Copied from the record:

| arm | classifier tokens in / out | requests, latency each | non-translate verdicts | agreement with c_final (luna) | agreement with c_before (luna) |
|---|---|---|---|---|---|
| (a) before, F's mapping, gateway | 14.4k / 1.1k | 3, after four 503 retries (wall 36 s) | 7 | 27/31 | 26/31 |
| (a) after lean mapping only (`a_lean`) | 7.8k / 1.1k | 3 (wall 14 s) | 9 | 25/31 | not computed |
| (a) final, lean + gate | 7.8k / 1.1k | 3: 0.87, 0.37, 0.67 s | 9 | 25/31 | 26/31 |
| (b) luna schema (`c_final`) | 7.6k / ~1.4k | 3: 5.07, 6.03, 4.15 s | 5 | — | 30/31 |

The two luna runs agree 30/31 with each other, and the two Jev runs on the same code (`a_lean`, `a_final`) agree 29/31.

**The lean mapping halves Jev's prompt tokens (14.4k → 7.8k) and cuts wall time (36 s → 14 s incl. no retries); agreement did not move beyond Jev's own run-to-run variation.**

**The gate finding**, copied from the record:

> The official Jev's `confidence` field is not the chosen option's probability. On two options it is about `2p − 1` (0.51 → 0.02, 0.61 → 0.22, 0.76 → 0.53). The code keeps F's choice and reads `probabilities[choice]` first. On two options it never falls below 0.5, so a 0.5 gate does nothing.
>
> Replaying `a_final` offline: a gate at 0.75 on the probability, which is the same as 0.5 on Jev's own `confidence`, would turn all 5 low-confidence skips into translate and keep the 4 confident ones. Agreement with luna would go to 30/31. That is one book and 31 signatures.

That was one book and 31 signatures, so 0.75 was a hint, not a setting. The gate was then measured on a corpus: 0.95 on the chosen option's probability, over the 662 plan signatures of the 45-EPUB corpus against gpt-5.6-luna, with a lost skip costing 10 and an extra translate 1. At that value about nine of ten of Jev's skips fall back to `translate`, and what survives is apparatus: copyright lines, line numbers, note marks, index locators. Never skipping would cost 140 against 115 gated, so Jev's saving over translating everything is modest on this corpus.

## Decision

- Jev is available as a classify model: `--classify-model jev` at TypeSafe's own address, a gateway's id with `--classify-base-url` and `--classify-key`. See [Provider file and extra models](../providers.md#jev-and-jev-compatible-classifiers).
- The default classifier stays the translating model.
- First round: the abstain threshold stayed at 0.5; the four low-confidence skips were recorded, not judged (two URLs, a single letter and a bare author name, which a reading edition could keep or translate).
- Second round: the requests are lean (the shared text sent once), and the threshold became an asymmetric gate. A `skip` below it becomes `translate`; a `translate` is taken at any probability. Content is never lost to a doubtful skip.
- The gate is 0.95 on the chosen option's probability, from the corpus measurement above. `--classify-min-confidence P` moves it for a run (`BBM_JEV_MIN_CONFIDENCE` does the same without the flag; the flag wins).

What would change it: a comparison on more books, with verdicts judged against what a reader wants, and a price for both arms.

## Limits

- One book, 31 signatures, one run per arm in each round. Both rounds' agreement numbers, the lean mapping's token halving and the 0.75 replay rest on that one book.
- Only the gate value comes from the 45-EPUB corpus, and agreement there is still agreement with luna.
- The key was a gateway key, so TypeSafe's own address was not exercised live (it answered 401 to that key).
- Token counts only; no money comparison.
- Agreement with luna is not correctness: luna's verdicts were not judged either.

---

### En Evaluation Session Compact Budget (`docs/en/evaluation/session-compact-budget.md`)

# Session compact budget: `--context-compact-at`

## Abstract

In session mode the history grows until it reaches `--context-compact-at`, then the model writes a handoff report and a new window starts. A short budget means many seams; a long one means every request re-reads a long history. An 18-run paid study on three books measured cost against the budget. The curve was flat from 1500 to 4000, rose after that, and rose steeply at 20000. Continuity held across 44 seams at a budget of 2500. After the grouping defaults were halved on September 7, a second measurement found that 8192 costs **more** than 4096: +39.8% against +27.3% over the old defaults on a 300-unit run. **The default stays at 8192 anyway, for continuity, not price.** A lower value is the cheaper setting.

## Setup

**The cost study (September 5, 2026).** 18 paid runs, `gpt-5.6-luna` on OpenAI's endpoint, 3.267M tokens, `--language zh-hans`, `--use_context session`, cells sized for about 25 requests each. Three epub-samples books: childrens-literature (literary prose with recurring names), moby-dick-mo (long chapters) and epub30-spec (short, repetitive technical units).

| phase | cells | what it measures |
|---|---|---|
| 0 | 3 books at stock defaults | calibration: per-request overhead F, JSON wrapper j, handoff prompt F_h, report size K |
| 1 | C ∈ {1500, 4000, 8000, 20000} × 3 books, B=800 | the cost-vs-C curve |
| 2 | B ∈ {400, 1600} on moby-dick (800 from phase 1) | the cost-vs-B curve |
| 3 | one full book at the solved optimum (B=1088, C=2500) | out-of-sample validation |

Cost is stated in input-token-equivalents: `(tokens_in − tokens_cached) + 0.1·tokens_cached + 4.0·tokens_out`, the vendor's price ratio. The meter of the time did not count the compaction turns themselves (up to 38% of true cost at C=1500; fixed since), so every total is metered cost plus the reconstructed compaction turns.

**The price check of the new defaults (September 7, 2026).** animal_farm, `gpt-5.6-luna`, session mode, 300 and 150 units, the new grouping (16 units, budget 1200 to 1600) at C=4096 and C=8192, against the pre-September defaults (budget 2400 to 3200, C=8000).

## Results

Total price-weighted cost per book, September 5:

| book | C=1500 | C=4000 | C=8000 | C=20000 | C* solved |
|---|---|---|---|---|---|
| childrens-literature | **218.8k** | 220.1k | 228.4k | 340.7k | 2512 |
| moby-dick-mo | 207.9k | **201.7k** | 237.2k | 263.3k | 2191 |
| epub30-spec | 117.2k | **108.4k** | 111.9k | 169.0k | 1580 |

- Flat within 0.6 to 3% across 1500 to 4000 on all three books; all three solved optima land there.
- The results record puts 8000 at 4 to 18% above the flat region. The grouping report, written later from the same cells, puts it at 9 to 25% above each book's own optimum. Both describe the same runs, measured against different baselines.
- C=20000 costs up to 56% more, and it is the only cell where drift appeared: a 你→您 register shift within one long window.
- **Continuity survives short windows.** The full-book run at C=2500 crossed 44 seams with zero terminology breaks in 1012 nodes (皇帝 62/62, 夜莺 38/38, 公主 34/34, 安徒生 12/12). The handoff report works as an accumulating name table.
- The model behind the curve predicted the full-book run's request count exactly (82/82) and its cost within −6.2%. That run was cheaper per content token (11.7) than every grid cell (13.1 to 20.4).
- Fitted constants: F=104, j=11.3 per unit, F_h=72, K=538 (139 reports), ρ 1.41 to 1.74, g 1245/920/645 per book, r 1.12 to 1.18, α rising with C (0.042 to 0.213 on childrens).



*Figure: the September 5 cost model and cells. It marks the defaults of that day (C=8000, a 1600 to 2000 budget clamp), before the September 7 ruling. Open squares at 12000 and 16000 are model predictions.*

The price of the new grouping defaults, September 7:

| arm | 300-unit requests | all-in tokens | compactions |
|---|---|---|---|
| old defaults | 33 | 283,361 | 8 |
| new caps, C=4096 | 63 | 360,681 (+27.3%) | 19 |
| new caps, C=8192 | 52 | 396,197 (+39.8%) | 9 |

At 150 units: +5.9% at 4096 and +24.0% at 8192. Raising C from 4096 to 8192 brought the compaction count back to the old level (19 to 9, against 8), as intended, and made the bill worse. The halved budget multiplies the requests (33 to 52 or 63), and every request re-reads the carried history: the prompt load per request was 7007 (old), 4690 (at 4096) and 6537 (at 8192). Caching did not rescue it: 9k to 21k tokens cached against about 340k of prompt.



*Figure: the September 5 cost model (left) and the September 7 price check (right). Script: `docs/img/src/session_compact_cost.py`.*

## Decision

`--context-compact-at` defaults to **8192** on every session run, grouped or not, the Codex route included. The run prints it at start.

The reason is continuity, for the many people who run this tool on local-device models, whose cost curve is not the hosted, cached one measured here. It is **not** the cheap setting. Under the current grouping budget, a lower value is cheaper: at 300 units, 4096 used 360,681 tokens against 396,197 at 8192. Past 16000 the cost rises steeply, and the only drift ever observed lived in the long-window cell.

What would change it: a new measurement of both cells (4096 and 8192) under a changed grouping budget, or a continuity measurement on local models. Do not move either number without re-running those two cells.

For you: leave it unset for the fewest seams. Set it lower (toward 4096) if session cost matters more to you than seams. Set it to your model's input limit if that is smaller. The minimum is 1500.

## Limits

- One model (`gpt-5.6-luna`) on one endpoint with prompt caching. Uncached and local endpoints were not measured.
- Each cell was a single run of about 25 requests; differences under about 30% on one run are close to noise.
- The September 7 price check used one book (animal_farm) at two sizes.
- The continuity result rests on one full book (childrens-literature, zh-hans).

---

### En Features Pdf Ocr Engines (`docs/en/features/pdf-ocr-engines.md`)

# Which OCR engine

When a PDF page is a scan, `--pdf-ocr` has an OCR engine read it. `--ocr-engine` chooses which one. Four run on your own machine; they differ in what they read well, what they install and whether they download anything.

```bash
python make_book.py --book_name scan.pdf --to-epub --pdf-ocr \
  --ocr-engine ocrmac --ocr-lang iso:en --language zh-hans
```

Try two pages first (`--pages 1-2`) and read `source.md` in the bundle before you pay for a translation. The run prints the engine it used (`OCR engine: …`).

## If you need X, use Y

| your situation | use | why |
|---|---|---|
| A Mac, and you do not want to download anything | `ocrmac` (already there with the `pdf` extra) | Apple's own engine, part of macOS: no model download. The fastest in the measurement (6 s per two pages) and second-best on English, tied with the best on typewriter and clean pages. Weaker than rapidocr on simplified Chinese. |
| You have not chosen | `auto` (the default) | Takes the first installed of ocrmac, rapidocr, easyocr, and names it. With the `pdf` extra that is ocrmac on a Mac and rapidocr elsewhere. |
| Simplified Chinese | `rapidocr` | Best on simplified Chinese: mean Han-character error 0.054, against ocrmac 0.097 and tesseract 0.116. |
| Traditional Chinese (horizontal) | `ocrmac` or `rapidocr` | Close on the one page measured (0.043 and 0.060). tesseract (0.350) and easyocr (0.724) were far behind. Vertical text comes out in the wrong column order with any engine: check `source.md`. |
| Japanese or Korean | `ocrmac` or `rapidocr`, with `--ocr-lang iso:ja` or `iso:ko` | Not measured. Both engines have these languages; tesseract needs the `jpn` or `kor` language file installed. |
| English typewriter or other degraded scans | `tesseract` or `ocrmac` | Tied on typewriter pages (error 0.082 and 0.086; rapidocr 0.206). On old print and a magazine, tesseract was clearly ahead (0.222; ocrmac 0.361, rapidocr 0.383). |
| A machine without a GPU | anything but `easyocr` | Every number here was measured on the CPU. easyocr took 37 s per two pages (others 6 to 12 s) and up to 6.6 GB of memory. |
| Linux or Windows | `tesseract` for English, `rapidocr` for Chinese | ocrmac exists only on macOS. |
| A script none of the above reads | `easyocr` | It has models for 80+ languages. In the measurement it was last on English and Chinese and dropped text. |

## Installing each engine

| engine | install | downloads on first use | size |
|---|---|---|---|
| `rapidocr` | Comes with the `pdf` extra, with onnxruntime. | Nothing: its models are inside the package. | rapidocr 31 MB, onnxruntime 62 MB |
| `ocrmac` | Comes with the `pdf` extra on macOS (macOS only). | Nothing. | 28 MB |
| `easyocr` | `pip install easyocr` | A detector (83 MB) and one model per script: English 15 MB, Latin 15 MB, simplified Chinese 22 MB, traditional Chinese 226 MB, into `~/.EasyOCR/model`. | about 150 MB of packages |
| `tesseract` | The tesseract program and its language files, on PATH: `brew install tesseract` on macOS, `apt install tesseract-ocr tesseract-ocr-chi-sim` on Debian and Ubuntu, the installer from the tesseract project on Windows. | Nothing: add a language by installing its file (`eng` 4 MB, `chi_sim` 2.5 MB, `chi_tra` 2.4 MB in the fast set). | 25 MB program plus its libraries |

All four work off-line once installed and, for easyocr, once its models are downloaded.

An engine you name that is not installed, or `ocrmac` on Linux or Windows, is refused before any page is read, with the line that installs it. `auto` is never refused.

If you keep tesseract's language files in a folder of your own (`TESSDATA_PREFIX`), put tesseract's `configs` folder in it too: without it tesseract cannot write the table docling reads, and the extraction fails.

## Language codes

`--ocr-lang` takes each engine's own codes, or a portable `iso:` tag that works on every engine. When you switch engines, the `iso:` tag is the one to use.

| language | any engine | rapidocr | ocrmac | easyocr | tesseract |
|---|---|---|---|---|---|
| English | `iso:en` | `en` | `en-US` | `en` | `eng` |
| Simplified Chinese | `iso:zh-Hans` (or `iso:zh`) | `ch` | `zh-Hans` | `ch_sim` | `chi_sim` |
| Traditional Chinese | `iso:zh-Hant` | `chinese_cht` | `zh-Hant` | `ch_tra` | `chi_tra` |
| Japanese | `iso:ja` | `japan` | `ja-JP` | `ja` | `jpn` |
| Korean | `iso:ko` | `korean` | `ko-KR` | `ko` | `kor` |

rapidocr reads one language per run (the first). Without `--ocr-lang` each engine reads its own defaults: rapidocr Chinese and English, ocrmac, easyocr and tesseract English, Spanish, French and German. A scan in another script then comes out wrong.

## What was measured

20 scanned pages (14 English, 6 Chinese), the same language asked of each engine, all on the CPU. Error is the character error rate against a typed transcript: 0 is perfect, and it counts a paragraph put in the wrong place as errors.

| pages | rapidocr | ocrmac | easyocr | tesseract |
|---|---|---|---|---|
| English typewriter (3) | 0.206 | 0.086 | 0.100 | **0.082** |
| English magazine and old print (3) | 0.383 | 0.361 | 0.609 | **0.222** |
| English, clean synthetic scan (4) | 0.069 | **0.019** | 0.070 | **0.019** |
| English, degraded synthetic scan (4) | **0.080** | 0.124 | 0.333 | 0.116 |
| Simplified Chinese book scan (1), Han characters | **0.009** | 0.056 | 0.235 | 0.036 |
| Traditional Chinese scan (1), Han characters | 0.060 | **0.043** | 0.724 | 0.350 |
| Simplified Chinese synthetic scans (4), Han characters | **0.066** | 0.107 | 0.709 | 0.136 |
| Time per two pages (median) | 12 s | **6 s** | 37 s | 10 s |

Who won where:

- **English:** tesseract, with ocrmac close behind. On typewriter and clean pages the two are tied (differences under 0.005). tesseract's lead is real on old print. On the degraded synthetic pages rapidocr's lower number comes from reading order: ignoring order, all three read the words equally well.
- **Simplified Chinese:** rapidocr, on the mean and on 3 of 5 pages; ocrmac was ahead on one clean synthetic page, ocrmac and tesseract on one degraded one.
- **Traditional Chinese:** ocrmac and rapidocr, within noise of each other on one page.
- **easyocr** was last on every class but typewriter, the slowest, and the heaviest in memory.

Every number, the pages, the scorer and the commands: [OCR engines on scanned pages](../evaluation/pdf-ocr-engines.md).

---

### En Features Pdf To Epub (`docs/en/features/pdf-to-epub.md`)

# PDF to bilingual EPUB

If you want a PDF as a bilingual book with a table of contents, read two pages first, then run the whole file:

```bash
python make_book.py --book_name paper.pdf --to-epub --pages 1-2 --test
python make_book.py --book_name paper.pdf --to-epub --use_context session
```

Read `paper_pages-1-2_book/source.md` between the two. [Recommended settings for PDF](recommended-pdf.md) has the command for each kind of document and each system.

The route has been checked on arXiv papers and a set of books, scans and browser-saved pages, not on every PDF shape. Issues and pull requests are welcome; attach the PDF if it can be shared, or the page of `source.md` that came out wrong.

## What it does

`--to-epub` reads the PDF with [docling](https://github.com/docling-project/docling)'s layout and table models into Markdown, translates that Markdown with the Markdown loader, and has Pandoc build a reflowable bilingual EPUB. Every paragraph is followed by its translation, and the table of contents follows the headings. Heading levels are read from the page itself: numbering first (`1.`, `1.1`, `I.`, `A.`), then font size and weight. Figures stay pictures, and each display formula is cropped from the page as a picture and placed where it stood. Pages with no text layer, a scan, are read by the OCR models only when you pass `--pdf-ocr`; a scan whose text layer is wrong can have it replaced with `--ocr-replace-layer`. If you name a vision model with `--img-model`, it looks at each page and corrects the roles docling gave the regions (a heading that is really an author line, a code listing read as footnotes) before the Markdown is written.

Everything lives in a working folder beside the PDF, `<name>_book/`: `source.md` (the extraction), `images/`, `book_bilingual.md` (the translation) and a manifest. The finished book is copied out as `<name>_bilingual.epub`. Rerunning the same command reuses the extraction and a finished translation, so you can stop, read `source.md`, fix a heading, and go on without paying twice. A PDF is a page description, not a document: it stores glyphs at positions and knows nothing of paragraphs or headings, so every extractor guesses the structure back. A heading one level off or a table that arrives as prose is the format showing through, and a minute's edit in `source.md`.

`source.md` marks where each page starts with a comment, ``. N is the page's number in the PDF file, counted from 1: the numbers `--pages` takes, not the number printed on the page. A page with nothing on it keeps its marker, so the numbering never slips. Rarely, docling returns an item with no page. Such items are written once, at the end of `source.md`, under ``, and the run says how many. Their place in the reading order is lost: move them where they belong, or delete them, before you translate.

## Setup

1. Install the PDF extra from a clone of the repository, `pip install ".[pdf]"` (Linux without an NVIDIA card and Windows with one add PyTorch's own index: [Installing the PDF extra](../installation-pdf.md)). You also need Pandoc **3.1.12 or newer** on PATH. No Java.
2. The models (about 500 MB) download on the first run.
3. Read the first two pages before anything longer, and read `source.md`, the headings at least, before you pay for the full translation. They become the table of contents.

The route's flags:

| flag | what it does |
|---|---|
| `--to-epub` | Take this route. |
| `--pdf-ocr` | Read pages that have no text layer. Off by default: a born-digital PDF is already readable, and OCR costs several times the time without changing what is read. Layout, heading and table detection run either way. |
| `--ocr-replace-layer` | With `--pdf-ocr`: OCR every page and use that text instead of the text layer the PDF carries. Off by default: a layer is kept and only pages without one are OCR'd. For a scan whose layer is wrong (another language, or garbage from a poor OCR); on a clean scan the layer reads better than the local engine. Turning it on or off reads the PDF again on the next run. |
| `--ocr-lang LANGS` | With `--pdf-ocr`: the languages the OCR engine reads, in its own codes. `auto` picks ocrmac on a Mac and rapidocr elsewhere; the pdf extra installs both, and neither downloads anything. [Which OCR engine](pdf-ocr-engines.md) has the measurement. The codes: rapidocr `ch`, `en`, `latin`; easyocr `ch_sim`, `ja`, `ko`; ocrmac `zh-Hans`, `ja-JP`. A BCP-47 tag behind `iso:` (`iso:zh`, `iso:ja`, `iso:zh-Hant`) works on every engine, so it is the safe choice when you do not know which one will run. rapidocr has no `ch_sim`: for Chinese write `iso:zh`. rapidocr reads one language per run and uses the first. The run prints the engine and languages it used. |
| `--ocr-engine ENGINE` | With `--pdf-ocr`: which OCR engine reads the pages: `auto` (default; the first installed of ocrmac, rapidocr, easyocr), `rapidocr`, `ocrmac` (macOS), `easyocr` or `tesseract`. An engine that is not installed is refused before any page is read. Which to choose, measured: [Which OCR engine](pdf-ocr-engines.md). |
| `--device auto\|cpu\|cuda\|mps\|xpu` | Where the models run. `auto` detects CUDA or MPS and falls back to the CPU. The CPU gives the same text, only slower. |
| `--pages 12-30` | Only these pages, numbered from 1 (`1,3,5-7` works too). The book gets its own names: `<name>_pages-12-30_book/`, `<name>_pages-12-30_bilingual.epub`. |
| `--no-formula-images` | Leave display formulas as `` placeholders. Almost never what you want: the parser never reads equations, so without the pictures the mathematics is missing. |
| `--pdf-image-dpi N` | How sharp the figures are, in dots per inch of the PDF's own page size (72 to 600). Default 200: sharp on a tablet or a high-density e-reader; 150 for a smaller book, 300 for figures with tiny labels. A rerun at another value redraws the figures only; the extraction and the translation are kept. Formulas keep their own resolution. A figure that is a single embedded picture keeps that picture's own resolution when it is lower, and no figure goes above 5.6 megapixels. |
| `--img-model MODEL` | A vision model that corrects region roles from the page image. Off unless named here or as the provider entry's `img_model`; never the translating model by fallback. `none` turns an entry's model off. See [Correcting region roles](#correcting-region-roles-with-a-vision-model). |
| `--img-base-url URL` | Where that model is served, when it is not the run's endpoint (OpenAI-compatible only). |
| `--img-key KEY` | The key for `--img-base-url`. Defaults to the run's key on the run's own endpoint. |

Every Markdown-loader flag works unchanged: `--use_context session` (recommended), `--glossary`, `--parallel-workers` (not with a session), `--no-thinking`, `--test`. The full list is on [PDF](../formats/pdf.md). A combination the translation would refuse, such as `--no-thinking` on the codex route, is refused before the extraction starts, so nothing is paid for first. `--classify-model` does nothing on this route yet; the run warns.

## Correcting region roles with a vision model

docling cuts each page into regions and labels them: text, heading, title, caption, footnote, code, table, picture. Some labels are wrong, and the book shows it: an author line in the table of contents, a code listing printed as prose, a figure label promoted to a section. With `--img-model`, each page is shown to the model with docling's regions drawn and numbered on it. The model answers one role per region (text, heading, title, caption, footnote, code) or abstains. Accepted answers replace the labels before the Markdown is written, so the contents and the code blocks come out right. The text itself is never rewritten.

- **What it cannot fix.** Lists, tables, pictures, formulas and running headers and footers are not asked about. A scanned page that docling has shattered into fragments is not rebuilt by relabeling them.
- **What it costs.** About 3,000 prompt tokens per page; 2.7 to 10.6 s per page in the study. In the study behind it, the model fixed 40 of 66 wrong labels on 12 pages; see [Where an LLM fixes layout](../evaluation/pdf-structure-llm-roles.md).
- **What it needs.** An OpenAI-compatible endpoint that accepts images. The run checks once that the model can read a picture. If it cannot, the run says so and extracts with docling's own labels.
- **The model is named, never assumed.** The step runs only on a model you name with `--img-model` or in the provider entry's `img_model`. The shipped `openai` entry names `gpt-6-luna`, so `--provider openai` turns the step on; `--img-model none` turns it off. An on-device translating model is never asked to read a page.
- **Changing it extracts again.** The image model and its address are part of what the extraction is compared against, so adding, changing or dropping `--img-model` reads the PDF again. A bundle whose role pass did not finish is also read again when a run asks for the pass.

The decisions are kept in `<name>_book/.work/extraction/decisions.json`. At the end of the extraction the run prints the pass's token usage on its own line: `Image model (<model> at <address>): …`.

## Mend a translation by hand

If a translated paragraph is wrong, correct it in `<name>_book/book_bilingual.md`, then take two steps:

1. Run `python tools/pdf_to_book.py export path/to/<name>_book` from the repository folder. It rebuilds `<name>_book/book_bilingual.epub` from your edit; nothing is extracted or translated again, and no key is needed.
2. Copy `<name>_book/book_bilingual.epub` over `<name>_bilingual.epub` beside the PDF. The export writes only inside the working folder, so the book beside the PDF keeps the old text until you copy it.

## Recommended commands

Every PDF starts with two pages and a few translated blocks (`--pages 1-2 --test`), then a read of `source.md`. The command for a novel, a textbook, a paper, a scanned book, a scan with a bad text layer and a Chinese scan, and the install and device for macOS, Linux with NVIDIA, Windows, a CPU-only machine and Docker, are on [Recommended settings for PDF](recommended-pdf.md).

## Known limitations

- Inline math equations may not be displayed correctly. Only display formulas are cropped as pictures; maths inside a sentence arrives as whatever text the extractor read from it.

## What can go wrong

Every failure on this route prints one line starting with `Error:`, before anything is paid for where it can. These are the lines, and what to do.

### Before extraction

- **`Pandoc is required for --to-epub. Install it and make sure pandoc is on PATH.`** Install Pandoc 3.1.12 or newer from [pandoc.org](https://pandoc.org/installing.html).
- **`pandoc 3.1.3 is too old for EPUB export; Pandoc 3.1.12 or newer is required …`** Your Pandoc came from apt (Ubuntu 24.04 ships 3.1.3, Debian 13 ships 3.1.11). Install the release and put it first on PATH; the line names the harness `tools/pdf_to_book.py --pandoc PATH` as the other way.
- **`reading a PDF needs the pdf extra, which is not installed.`** From a clone, `pip install ".[pdf]"` (step 3 of [Installing the PDF extra](../installation-pdf.md)). Not `pip install "bbook_maker[pdf]"`.
- **`--device cuda was asked for, but the installed PyTorch is a CPU-only build.`** Reinstall through the CUDA route. **`… but this machine has no cuda accelerator available.`** Use `--device cpu` or `--device auto`.
- **`--parallel-workers is not supported with --use_context session …`** Choose one.
- **`--no-thinking has no request to travel in on the codex route: …`** Drop `--no-thinking` on codex.
- **`--img-model needs an OpenAI-compatible endpoint; … resolves to the … format.`** The image model is asked at the run's endpoint, or at `--img-base-url`, and that endpoint is not OpenAI-shaped. Give an OpenAI-compatible `--img-base-url` (and `--img-key`), or drop `--img-model`.
- **`--img-base-url names where --img-model is served, and no --img-model was given. …`** Name the model too.
- **`--ocr-replace-layer re-reads pages that already carry a text layer with the OCR engine, so it needs --pdf-ocr.`** Add `--pdf-ocr`.

### During extraction

- **`N of M selected pages have no text layer (page(s) …); rerun with --pdf-ocr to read them with the OCR models.`** The PDF (or part of it) is a scan. Add `--pdf-ocr`.
- **`No --ocr-lang given: the OCR engine reads its own default languages, which may not be the pages'; …`** Then **`OCR engine: rapidocr (docling's choice on this install), languages: the engine's defaults.`** Check `source.md`. If the scan is not in Chinese or English, rerun with `--ocr-lang`; the bundle is read again.
- **`The parser produced no text for a document whose pages have no text layer; the OCR pass returned pictures only.`** or **`Warning: no text was recognised on page(s) …`** The engine could not read the script. Rerun with `--ocr-lang` for the page's language.
- **`The parser returned no text for this PDF; there is nothing to translate. If its pages are scans, rerun with --pdf-ocr.`** As it says.
- **`Warning: page N extracted C characters, several times what a printed page holds; inspect source.md before translating.`** Something on that page, usually a figure, carries far more text than it shows. A page whose Markdown is thousands of lines is junk, not a long page. Remove it from `source.md` or leave the page out with `--pages`.
- **`The PDF carries JBIG2 image masks, which docling-parse renders wrongly (docling issue #4329); page images are rendered by pypdfium2 instead, the text layer still by docling-parse.`** Information. Scans from the Internet Archive and ABBYY FineReader often use such masks, and docling-parse would draw the page as a smear. The run takes the page image from pypdfium2 for the whole document.
- **`N of M selected pages carry only an invisible OCR text layer (a scanned book with recognised text underneath); that layer is kept as the page's text …`** Information. With `--pdf-ocr` the layer may be mixed with what the OCR engine reads, so name the language with `--ocr-lang`; in the wrong language a readable page turns to garbage. If the layer itself is garbage, rerun with `--pdf-ocr --ocr-replace-layer` and the language.
- **`OCR: replacing the embedded text layer on every page (--ocr-replace-layer)`** Information. Without `--ocr-lang` the language hint follows.
- **`page N: the OCR engine read nothing where the PDF carried a text layer; …`** With `--ocr-replace-layer`, that page came back empty and stays an empty page: its layer is not used in its place. The line also goes into the manifest's limitations; past ten pages the rest are counted (`... and N more`). Rerun with `--ocr-lang` for the page's language, or without `--ocr-replace-layer` to keep the layer.
- **`The OCR engine read nothing on any selected page, and with --ocr-replace-layer the PDF's text layer is not used, so there is nothing to translate; …`** The run stops before any translation is paid for. The manifest records the failed attempt: the reason, the empty pages and the settings used. The usual cause is the wrong `--ocr-lang`, or none on a scan outside the engine's default languages. Rerun with the right one, or without `--ocr-replace-layer` to keep the layer.
- **`N item(s) carry no page number; they are placed after the last page in source.md.`** Rare. docling returned items without a page. They are at the end of `source.md`, under ``, and their place in the reading order is lost. Move them where they belong, or delete them, before you translate.
- **`… has no model for the OCR language 'ch_sim'. …`** The engine does not know that code; the message lists the ones it does. On rapidocr, write `iso:zh` for Chinese. Nothing was read.
- **`… did not read the probe image (…); image steps are skipped this run.`** The image model cannot see pictures at that endpoint. The extraction goes on with docling's own labels.
- **`Region roles: A of B asked items changed by <model> (… kept, … rejected, … pages with many changes); overlay at <path>.`** Information: what the image model changed. **`Region roles: C of D asked items on page N changed; read that page in source.md before translating.`** Most of a page changed; the changes are kept, so look at it. **`Region roles: …; the detector's own labels stand there.`** Part of the pass did not finish (a budget ran out, an answer was rejected, a region went unanswered); those regions keep docling's labels.
- **`Extracting again: the bundle's region-role pass is <status>; this run asks for --img-model …, which only a complete pass satisfies.`** Information. The earlier pass did not finish, so the PDF is read again.
- **`--img-model <model> failed: …`** The image endpoint answered with an error that waiting does not fix, such as a rejected key. The run stops. Fix the key (`--img-key`) or the address.

### After extraction, before translation

- **`The document does not open with a top-level heading, so a heading "<name>" was added above its text; …`** The EPUB's contents need a level-1 heading at the start. Rename it in `source.md` before translating if you like.
- **`Page 12: the selection starts inside a section, so a heading "Page 12" was added above its prose; …`** The same, for a `--pages` range that starts mid-section.
- **`The page selection is not one run of pages, so pages 1-7 were read and the ones outside the selection dropped afterwards; …`** A range with a gap reads everything it spans. A single range reads fewer pages.
- **`Display formulas kept as images: N. …`** Information: the equations are pictures and are not translated.
- **`Warning: a formula region on page N covers S% of the page, which is a layout mistake rather than an equation; …`** That region stays a placeholder instead of a picture of the whole page. The other `Warning: … formula …` lines mean one formula could not be placed; its placeholder stays.
- **`Extraction: removed N control character(s) that docling read from glyphs on page(s) …; they are not text and would break the EPUB.`** Information. docling read some symbols (often a minus or an approximately-equal sign) as invisible control characters, and they were taken out of `source.md`. The symbol they stood for is missing on those pages; put it back in `source.md` if it matters.
- **`Unsupported Markdown structure: control character U+0000 at line N. …`** `source.md` still holds a control character (a bundle extracted before the line above existed, or an edit). Delete it and rerun; nothing has been translated yet.
- **`Unsupported Markdown structure: raw tex '\s' at block 29 … Normalize the source before translation.`** The extractor does not escape Markdown in prose, so a sentence containing `\s`, `[u](y)` or `<k>` reads as raw TeX, a link or HTML. Escape it in `source.md` and rerun; the extraction is not repeated.
- **`Reusing the extraction made with docling … on …; this run would use docling … on …. Delete the bundle directory to extract again.`** Information. The extraction came from another device or docling version.

### Translation and export

- **`Translation reused: … (same source and settings; delete it to translate again).`** Information. Delete `book_bilingual.md` to translate again.
- **`Error: translate failed: Source or translation settings changed; start a new translation bundle.`** You changed the model, the language or `source.md` after a partial translation. Rerun with the original settings, or move the bundle aside and start over.
- **`Error: … Bilingual Markdown was edited; rebuild the EPUB from it with: python tools/pdf_to_book.py export <name>_book, then copy <name>_book/book_bilingual.epub over <name>_bilingual.epub.`** You edited `book_bilingual.md` by hand. That is allowed, but the run will not overwrite it. Take the two steps the line names (it carries the full paths): the export rebuilds the book in the working folder from your edit, and the copy puts it beside the PDF; see [Mend a translation by hand](#mend-a-translation-by-hand). To translate again instead, delete `book_bilingual.md`.
- **`EPUB navigation is invalid: …`** The headings do not form a usable table of contents. Fix the heading levels in `source.md` (one `#` title, then `##`, `###`) and rerun.
- **`Interrupted. Rerun the same command to resume.`** Ctrl+C. Rerun; the stages that finished are not repeated.

### After the run

- **`Image model (<model> at <address>): tokens: …`** The image model's own usage, printed after the extraction, apart from the translation's.
- **`Nothing on this route classifies yet, so --classify-model is ignored on a … book.`** A classify flag or a provider's `classify_model` reached the translation of `source.md`, where nothing classifies. Harmless.

### Limits to know

- Figures stay pictures and their labels are not translated.
- The EPUB carries no `bbm_translation_metadata.json`, and `--no_disclosure` is not honored on this route yet: the credit line is always added.
- `--glossary-auto` learns only when a compaction happens, so a short paper at the default budget learns nothing.
- `--ocr-replace-layer` on a scan with a good visible layer reads worse than the layer (fewer characters, visible misreadings), so keep it for a layer that is wrong. On a scan with an invisible layer the two give nearly the same text.
- With an invisible layer, `--pdf-ocr` alone already has the OCR engine read the page, which is why the Chinese numbers above are close. Keeping such a layer untouched while OCR is on is not possible yet.
- The manifest records a failed attempt only for the all-empty stop.

---

### En Features Plan Mode (`docs/en/features/plan-mode.md`)

# Plan mode

## What it does

An EPUB keeps its text in many kinds of markup: paragraphs, headings, list items, table cells, blockquotes, verse lines, captions. Plan mode finds all of them. The loader partitions the whole book into units, groups the units by their tag signature (the tag and its classes), and asks the model which signatures are worth translating. The answer is written to `<book>_plan.json`, which every later run of the same book reuses. Without a plan, only the `--translate-tags` selection is translated, `<p>` by default, and verse or a table outside `<p>` is left in the source language without a word.

Plan mode also groups the work. Consecutive units share one request up to a token budget (`--accumulated_num`) and a unit cap (`--max-batch-units`, default 16). Each request asks for the units back by id, so a reply that drops, merges or reorders units is caught and retried in smaller pieces instead of shifting every translation by one slot. Plan mode is on by default for an EPUB on any LLM route; you do not need a flag. The numbers behind the defaults are on [Units and tokens per request](../evaluation/grouping-batch-size.md).

## Setup

Nothing to install. Plan mode needs an EPUB and a model to classify with. That is the translating model on any LLM route. On a [translation-service](../machine-args.md) route there is no model to ask, so the run falls back to the `--translate-tags` selection, unless you name a classifier with `--classify-model` (see [Choosing the classifier](#choosing-the-classifier)).

The flags:

| flag | what it does |
|---|---|
| `--plan-classify auto` | Default. The translating model decides, over a JSON schema where the endpoint verifiably applies one, otherwise over a plain conversation with one-word `skip`/`translate`/`unsure` answers. Unsure and unreadable answers translate. |
| `--plan-classify model` | Like `auto`, but an unresolved row stops the run instead of falling back. |
| `--plan-classify all` | Translate every unit; no classification, no plan file. |
| `--plan-classify none` | No plan: translate the `--translate-tags` selection, as before plan mode existed. |
| `--plan-classify agent` | Write the plan with samples, print instructions for a coding agent (or you), and stop. Rerun the same command to translate. |
| `--classify-model MODEL` | Classify with another model, or with a Jev-compatible classifier (`jev`). Named on the command line, it puts the run in `model` mode, so a classification failure stops the run. `--plan-classify-model` is the old name and still works. |
| `--classify-base-url URL` | Where that model is served, when it is not the run's endpoint: an OpenAI-compatible address, or a Jev-compatible classifier's URL. |
| `--classify-key KEY` | The key for `--classify-base-url`. |
| `--plan-dry-run` | Print the per-signature coverage table, write the plan with every decision empty, and exit. No key needed. |
| `--plan-min-coverage FRACTION` | Stop when the plan covers less than this share of the book's text (default 0.5; `0` turns the check off). |
| `--accumulated_num N` | Token budget per request. Unset, the run derives it: 1200 with the stock prompts, up to 1600 under a long custom `--prompt`, 800 on an endpoint without a strict schema (session runs keep the un-halved value). `1` turns grouping off. |
| `--max-batch-units N` | Most units per request. Default 16; 8 on an endpoint without a strict schema. |
| `--exclude-translate-tags TAGS` | Tags whose content is never sent (default `sup,code`). |
| `--only_filelist`, `--exclude_filelist` | Plan only these internal files, or skip these. The dry run honors them too. |

Do not pass these in plan mode: `--translate-tags` (ignored, the plan covers everything), `--allow_navigable_strings` (ignored), `--block_size` and `--batch_size` (they re-cut text the plan already partitioned). `--retranslate`, `--batch` and `--sentence_mode` contradict a plan and are refused.

## Choosing the classifier

The classifier is found in this order: `--classify-model`, then the provider entry's `classify_model`, then the translating model.

- **How it is asked.** Where the classifier's endpoint verifies a strict JSON schema, one request carries a page of signatures and the reply is held to the schema. Elsewhere it is a plain conversation: five signatures per turn, each answered `skip`, `translate` or `unsure`. Two missed replies in a row step down to three per turn, then to one. Below one, classification stops and the remaining signatures are translated. Each step prints one line.
- **A classifier of its own** (named by flag or provider entry) plans the book whatever the translating route can do. The run prints `plan mode: on (classified by …)`. This is how a translation-service route gets a plan. Google Translate with gpt-5.6-luna as the classifier was run end to end on the test book: all 31 signatures decided, coverage 99.8%.
- **Agent mode asks no model.** `--plan-classify agent` hands every undecided row to you or your coding agent. A named classifier does not pre-fill the plan, because an agent judges worse from pre-filled answers; the run warns that it is ignored. `--plan-classify all` ignores it too.
- **Where it is asked and with which key** is on [Provider file and extra models](../providers.md#where-each-model-is-asked). A classifier on its own address prints its own usage line at the end of the run.
- **Jev**, TypeSafe's classifier, is built for exactly this question: translate or skip, a page of signatures per request, in one cheap round trip. `--classify-model jev` uses it; a gateway in front of TypeSafe (Vercel's, or Cloudflare AI Gateway) works too. Which key goes where, and the URL rules, are on [Provider file and extra models](../providers.md#jev-and-jev-compatible-classifiers).
- **Jev's gate.** A doubtful `skip` becomes `translate`, so no content is lost to it. The gate is 0.95 on the probability of Jev's chosen answer, measured over 662 plan signatures from 45 EPUBs against gpt-5.6-luna. At that value about nine of ten of Jev's skips fall back to `translate`; what it still skips is apparatus (copyright lines, line numbers, note marks, index locators). On that corpus Jev saves little over translating everything. The plan file marks each fallback on its row (`… below the gate: translate`). `--classify-min-confidence P` moves the gate for a run (`BBM_JEV_MIN_CONFIDENCE` does the same without the flag); lower keeps more of Jev's skips, at your risk. See [Jev as the plan classifier](../evaluation/plan-classifier-jev.md).

=== "A translation-service route with an LLM classifier"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --api_format google \
      --classify-model gpt-6-luna \
      --language zh-hans
    ```

    The classifier goes to OpenAI and reads `OPENAI_API_KEY`. The translation stays with Google.

=== "Jev from the shipped provider file"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --provider openai-jev \
      --use_context session
    ```

    The one-line way: translates with gpt-6-luna at OpenAI (`OPENAI_API_KEY`) and classifies the plan with Jev (`JEV_API_KEY`), both from the `openai-jev` entry of the shipped `bbm_providers.example.json`.

=== "Jev at TypeSafe"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --model gpt-6-luna \
      --classify-model jev \
      --use_context session
    ```

    Reads `JEV_API_KEY` or `TYPESAFE_API_KEY`.

=== "Jev through a gateway"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --model gpt-6-luna \
      --classify-model typesafe-ai/jev \
      --classify-base-url https://ai-gateway.vercel.sh/typesafe \
      --classify-key "$GATEWAY_KEY" \
      --use_context session
    ```

    The key must be named: the Jev variables are sent on their own only to a typesafe.ai address.

=== "Jev through Cloudflare AI Gateway"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --model gpt-6-luna \
      --classify-model jev \
      --classify-base-url https://gateway.ai.cloudflare.com/v1/$ACCOUNT_ID/$GATEWAY_ID/custom-typesafe \
      --classify-key "$JEV_API_KEY" \
      --use_context session
    ```

    TypeSafe is a [custom provider](https://developers.cloudflare.com/ai-gateway/configuration/custom-providers/) on your gateway, slug `typesafe`, base URL `https://api.typesafe.ai`. For an authenticated gateway, set `CF_AIG_TOKEN`; if the gateway stores the TypeSafe key, leave out `--classify-key`.

## Recommended commands

The command per kind of book (novel, textbook, paper, dictionary), per endpoint (hosted, on-device, translation service) and per system is on [Recommended settings for EPUB](recommended-epub.md).

## What can go wrong

These are the lines the run prints, what they mean, and what to do.

- **`plan mode: on (endpoint verified strict JSON schema)`**, `plan mode: on (no structured output here; classifying over a plain session)` or `plan mode: on (classified by …)`. Not a problem. It says how the plan will be decided.
- **`plan: this endpoint missed the reply format twice at 5 per turn; continuing at 3 per turn`**. The plain-conversation classifier stepped down. Nothing to do; it costs more turns. **`plan: this endpoint missed the reply format twice even one at a time; classification stops here and the remaining N signature(s) are translated`**. The model cannot hold the one-word format. The book is still translated, generously. For a better plan, name a stronger classifier with `--classify-model`, or use `--plan-classify agent`.
- **`… doesn't apply JSON schema (…), using delimiter method`** or **`… honors JSON schema shape but not value constraints; using the delimiter method for translation, schema kept for classification`**. Not a problem. The endpoint does not do strict schema decoding, so translation uses a delimiter format. Expected on the anthropic route, most proxies and local servers.
- **`plan mode: off (…)`**. The run translates the `--translate-tags` selection. The reason in brackets says why: a route with no model, `--translate-tags` given, or a failed probe. If you did not expect it, read the reason. On a route that does not plan by itself, the run suggests `--plan-classify model`.
- **`N misaligned batches this run — if this keeps happening, a lower --max-batch-units or --accumulated_num may fit this model better`**. Printed from the third recovered batch. The model keeps miscounting large requests. Nothing is lost (each bad batch was retried in halves), but it costs requests. On the next run halve the unit cap: `--max-batch-units 8`, then `4`.
- **`Plan coverage X% is below the required 50.0% — refusing to translate a fraction of the book silently.`** The plan would skip most of the book. Open `<book>_plan.json` and look at what was marked `skip`. A dictionary or a book with a large apparatus may really translate less; then lower `--plan-min-coverage` on purpose.
- **`--classify-model names a classifier, and --plan-classify agent leaves every row to your agent and asks no model; it is ignored this run.`** (or `… all translates the whole partition …`). As it says; drop the flag or the mode.
- **`--plan-classify model asks an LLM to rule on every plan signature, and the google format translates through one fixed engine with no model to ask. …`** A fixed-engine run in `model` mode with no classifier of its own. Add `--classify-model`, or use `agent` or `all`.
- **`--classify-model needs an OpenAI-compatible endpoint; … resolves to the … format.`** A `--classify-base-url` that is not OpenAI-shaped. **`--classify-base-url names where --classify-model is served, and no --classify-model was given. …`** Name the model too.
- **`No API key for the jev classifier at … Pass --classify-key, …`** The Jev variables are read only for a typesafe.ai address. Through a gateway, name the key with `--classify-key`.
- **`BBM_JEV_MIN_CONFIDENCE must be a number from 0 to 1; got …`** Fix or unset the variable.
- **`<book>_plan.json has N undecided signature(s) …`**. After `--plan-classify agent`, some rows still have no decision. Fill every `action` with `translate` or `skip`, then rerun.
- **`…: invalid action '…' — use …`**. A typo in a hand-edited plan. Fix the JSON and rerun.
- **A resume refused with a fingerprint message.** The book or the plan changed since the checkpoint was written. Rerun with the original flags, or delete the checkpoint and start over, knowing what gets paid again.
- **The plan skipped something you wanted.** Delete `<book>_plan.json` and run again, or edit its `action` fields by hand. A `--test` run classifies the whole book, not only the slice, so its plan is the real one.

---

### En Features Recommended Epub (`docs/en/features/recommended-epub.md`)

# Recommended settings for EPUB

For most books this is the whole command:

```bash
bbook_maker \
  --book_name my_book.epub \
  --language zh-hans \
  --use_context session \
  --quiet
```

The book is translated through a [plan](plan-mode.md) (every block of text is found, and the model decides which kinds are worth translating), and a [session](session-mode.md) keeps names and terms consistent. The rest of this page is what to change when your book, your model or your machine is not the common case.

## If you need this, pass that

| if you need… | pass | read more |
|---|---|---|
| a first look for almost nothing | `--test --test_num 8` | [Quick start](../quickstart.md) |
| to see what will be translated and skipped before paying (no key needed) | `--plan-dry-run` | [Plan mode](plan-mode.md) |
| every block translated, no classification | `--plan-classify all` | [Plan mode](plan-mode.md) |
| to decide the plan yourself, or with a coding agent | `--plan-classify agent`, then rerun the same command | [Plan mode](plan-mode.md) |
| names and terms that must hold across the book | `--glossary names.txt` | [Session mode](session-mode.md) |
| your own voice or register | `--prompt my_prompt.json` | [Prompt files](../prompt.md) |
| only some chapters | `--only_filelist ch03.xhtml,ch04.xhtml` (names inside the EPUB) | [EPUB](../formats/epub.md) |
| a long book done faster, consistency second | `--parallel-workers 4` with bare `--use_context` (a session cannot be shared between workers) | [EPUB](../formats/epub.md) |
| the translation visually set apart | `--translation_style "color:#808080;font-style:italic"` | [Command line options](../cmd.md) |
| the translation only, without the original | `--single_translate` | [EPUB](../formats/epub.md) |
| no AI-translation credit line | `--no_disclosure` (a reader can then no longer tell the translation from a human one) | [EPUB](../formats/epub.md) |
| to continue after Ctrl+C or a crash | the same command plus `--resume` | [Quick start](../quickstart.md#if-a-run-stops) |

## By kind of book

=== "Novel"

    The command at the top of this page. If a recurring name drifts after a window seam, pin it with a glossary file:

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --language zh-hans \
      --use_context session \
      --glossary names.txt \
      --quiet
    ```

=== "Textbook with tables and formulas"

    Preview the plan first. A textbook puts text in table cells, captions and sidebars, and you want to see what will be skipped before you pay.

    ```bash
    bbook_maker \
      --book_name textbook.epub \
      --plan-dry-run
    ```

    If the table shows kinds of text you want that were not selected, translate every unit instead of classifying, and pin the terms that must hold across hundreds of pages:

    ```bash
    bbook_maker \
      --book_name textbook.epub \
      --language zh-hans \
      --plan-classify all \
      --use_context session \
      --glossary terms.txt \
      --quiet
    ```

    Formulas in an EPUB are MathML or images. MathML (`<math>`) and SVG are never sent to the model, so equations stay as they are.

=== "Paper"

    A paper as an EPUB takes the same command as a novel. A paper as a PDF: see [Recommended settings for PDF](recommended-pdf.md).

=== "Dictionary or critical edition"

    A book that is mostly apparatus (headwords, sigla, line numbers) legitimately translates less than half its text, and the run stops at the coverage check. Preview with `--plan-dry-run`, then lower the check on purpose:

    ```bash
    bbook_maker \
      --book_name edition.epub \
      --language zh-hans \
      --plan-min-coverage 0.2 \
      --use_context session
    ```

=== "A scan, or a Chinese scan"

    A scan is a PDF, and plan mode is EPUB only. See [Recommended settings for PDF](recommended-pdf.md). A Chinese EPUB translated to English works as a novel:

    ```bash
    bbook_maker \
      --book_name chinese_novel.epub \
      --language en \
      --use_context session
    ```

## By model and endpoint

| if your endpoint is… | pass | why |
|---|---|---|
| OpenAI, Anthropic, or a gateway with prompt caching | `--use_context session` | the history is re-read at the cache rate |
| an endpoint without prompt caching (`cached=` on the progress bar stays 0) | bare `--use_context` | a session would pay for the whole history on every request |
| Gemini or Qwen | `--api_format gemini --use_context` (or `qwen`) | they keep their own history and refuse a session |
| your ChatGPT plan | `--api_format codex`, no context flag | the thread is the context; see [Translating with an LLM](../llm-args.md) |
| a translation service (Google, DeepL, …) | `--classify-model gpt-6-luna` | the engine cannot classify, so without an LLM classifier only `<p>` is translated; see [Translation services](../machine-args.md) |
| a small on-device model (8B to 16B) | the defaults; add `--no-thinking` for a reasoning model, and `--context-compact-at` at the model's input limit | the run halves its request sizes by itself on an endpoint without a strict schema; see [On-device models](../llm-args.md#on-device-models-ollama-llamacpp-lm-studio) |
| a model that keeps misaligning (`N misaligned batches this run`) | `--max-batch-units 8`, then `4` | smaller requests; see [Units and tokens per request](../evaluation/grouping-batch-size.md) |
| a small model whose plan classification keeps failing | `--plan-classify all` | skips classification and translates every block |

## By system

The work happens on the endpoint, so a hosted endpoint needs the same command on every system. The system matters when the model runs locally.

=== "macOS (Apple silicon)"

    A local model through Ollama, which uses Metal. Bound the session window to a small model's context:

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --api_base http://localhost:11434/v1 \
      --model qwen3:8b \
      --use_context session \
      --context-compact-at 4000
    ```

    A small local model has no strict JSON schema, so the run halves the unit cap to 8 by itself.

=== "Linux with NVIDIA"

    A local server with an OpenAI-compatible API (vLLM, llama.cpp, Ollama) on the GPU:

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --api_base http://localhost:8000/v1 \
      --model your-model-id \
      --use_context session \
      --context-compact-at 4000
    ```

=== "CPU only"

    A local model on the CPU is slow for a whole book, and re-reading a history on every request is slower still. Use a hosted endpoint with prompt caching:

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --model gpt-6-luna \
      --use_context session
    ```

=== "Docker"

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:latest \
      --book_name /book/novel.epub \
      --use_context session \
      --quiet
    ```

    See [Docker](../docker.md).

---

### En Features Recommended Pdf (`docs/en/features/recommended-pdf.md`)

# Recommended settings for PDF

Start every PDF the same way: two pages, a few translated blocks, then read what came out.

```bash
python make_book.py \
  --book_name book.pdf \
  --to-epub \
  --pages 1-2 \
  --test
```

It extracts only those two pages and costs almost nothing. Open `book_pages-1-2_book/source.md` and read the headings at least: they become the table of contents. A page whose Markdown runs to thousands of lines is junk from a figure, not a long page. When it looks right, run the command for your document below. If something is off, fix it in `source.md` and rerun; the extraction is reused.

The route needs the [PDF extra](../installation-pdf.md) and Pandoc 3.1.12 or newer. What each flag does is on [PDF to bilingual EPUB](pdf-to-epub.md).

## If you need this, pass that

| if your PDF is… | pass | why | read more |
|---|---|---|---|
| any PDF you want as a book | `--to-epub --use_context session` | a reflowable EPUB with contents; a session gives each short block the text before it | [PDF to bilingual EPUB](pdf-to-epub.md) |
| a scan (no text layer) | `--pdf-ocr` | pages without text are refused until you pass it, so you never need to guess | [OCR flags](pdf-to-epub.md#setup) |
| a scan in Chinese, Japanese, Korean or another script outside the Latin alphabet | `--pdf-ocr --ocr-lang iso:ja` (or `iso:ko`, …) | the default OCR engine reads only its own languages: on a Mac (ocrmac) English, Spanish, French and German, elsewhere (rapidocr) Chinese and English | [OCR language](pdf-to-epub.md#setup) |
| a Chinese scan | `--pdf-ocr --ocr-lang iso:zh` (`iso:zh-Hant` for traditional) | not `ch_sim`: rapidocr refuses it | [Chinese scan](#by-document-type) |
| a simplified-Chinese scan | `--pdf-ocr --ocr-engine rapidocr --ocr-lang iso:zh` | lowest Han error in the measurement | [Which OCR engine](pdf-ocr-engines.md) |
| on a Mac, and you want no download | `--pdf-ocr` (auto picks ocrmac, which the pdf extra installs on a Mac; `--ocr-engine ocrmac` names it) | Apple's Vision framework, nothing to download; within a hair of the best on English and traditional Chinese, rapidocr better on simplified-Chinese scans | [Which OCR engine](pdf-ocr-engines.md) |
| a scan that already has an OCR layer (Internet Archive, ABBYY) | nothing extra | the run uses the layer and says so | [Scanned book](#by-document-type) |
| a scan whose text layer is garbage | `--pdf-ocr --ocr-replace-layer --ocr-lang <lang>` | every page is read again by the OCR engine | [Scan with a bad text layer](#by-document-type) |
| a paper, or anything with code listings | `--img-model gpt-6-luna` | a vision model fixes author lines taken for headings and listings read as footnotes, about 3,000 prompt tokens a page | [Correcting region roles](pdf-to-epub.md#correcting-region-roles-with-a-vision-model) |
| a long book you want a chapter at a time | `--pages 12-30` | each range gets its own book and never overwrites another | [PDF flags](../formats/pdf.md) |
| full of maths | nothing extra | display formulas are kept as pictures by default | [PDF flags](../formats/pdf.md) |
| translated with names or terms that must hold | `--glossary terms.txt` | pinned renderings, sent only with the blocks they occur in | [Session mode](session-mode.md) |
| on a machine whose GPU misbehaves | `--device cpu` | the same text, only slower | [By system](#by-system) |

## By document type

=== "Novel"

    A typed novel has few headings and no tables. Leave out front matter you do not want with `--pages`.

    ```bash
    python make_book.py \
      --book_name novel.pdf \
      --to-epub \
      --language zh-hans \
      --use_context session \
      --quiet
    ```

    Check the chapter headings in `source.md` first: a novel often sets chapters without numbering, and their level then comes from font size alone. The region-role pass was measured on papers, web pages and code listings, not on novels; with `--provider openai` it is on, and `--img-model none` saves its tokens.

=== "Textbook with tables and formulas"

    Tables are detected without OCR. Display formulas become pictures; the prose around them is translated, the equations are not.

    ```bash
    python make_book.py \
      --book_name textbook.pdf \
      --to-epub \
      --pages 12-30 \
      --language zh-hans \
      --use_context session \
      --glossary terms.txt
    ```

    Translate a chapter at a time with `--pages`; each range gets its own book. A textbook with code listings gains from `--img-model gpt-6-luna`: the listing's lines come out as code instead of footnotes. Inline mathematics inside a sentence is not a formula region and is not covered: it arrives as whatever the text layer or OCR made of it.

=== "Paper"

    A paper extracts into many short blocks; a session gives each block the text before it.

    ```bash
    python make_book.py \
      --book_name paper.pdf \
      --to-epub \
      --language zh-hans \
      --use_context session \
      --img-model gpt-6-luna
    ```

    Heading levels come out exact on most papers (187 of 195 headings across 20 arXiv papers). `--img-model` demotes an author line or a figure label taken for a heading, for about 3,000 prompt tokens a page; leave it out to spend nothing on it. Leave out the bibliography with `--pages` if you do not want to pay for it.

=== "Scanned book"

    The run refuses a page with no text layer until you pass `--pdf-ocr`.

    ```bash
    python make_book.py \
      --book_name scan.pdf \
      --to-epub \
      --pdf-ocr \
      --language zh-hans \
      --use_context session
    ```

    Without `--ocr-lang` the OCR engine reads its own default languages: on a Mac (ocrmac) English, Spanish, French and German, elsewhere (rapidocr) Chinese and English. A scan in another language needs `--ocr-lang`.

    **If the scan already carries an OCR layer** (Internet Archive and ABBYY FineReader files often do), try it without `--pdf-ocr` first: the run prints `… selected pages carry only an invisible OCR text layer …` and uses that layer, which usually reads better than a fresh local OCR. If the layer turns out to be garbage, see the next tab.

    `--img-model` does little on a scan: it cannot rebuild a page the layout detector shattered into fragments.

=== "Scan with a bad text layer"

    If `source.md` reads as nonsense although the page image is clear, the scan's text layer is wrong: recognised in the wrong language, or garbage from a poor OCR. `--ocr-replace-layer` drops it and reads every page with the OCR engine instead.

    ```bash
    python make_book.py \
      --book_name scan.pdf \
      --to-epub \
      --pdf-ocr \
      --ocr-replace-layer \
      --ocr-lang iso:zh \
      --language en \
      --use_context session
    ```

    Put the scan's own language in `--ocr-lang` (`iso:zh` here, for a Chinese scan): in the wrong language the engine reads nothing. Try two pages first. A page the engine read nothing on is named and left empty; if no page was read, the run stops before any translation is paid for. Do not use it on a scan whose layer reads well: the fresh OCR reads worse than a good layer.

=== "Chinese scan"

    Name the language the OCR engine should read. An `iso:` tag works whichever engine runs: `iso:zh` for simplified characters, `iso:zh-Hant` for traditional. Not `ch_sim`: rapidocr refuses it before reading a page.

    ```bash
    python make_book.py \
      --book_name scan.pdf \
      --to-epub \
      --pdf-ocr \
      --ocr-lang iso:zh \
      --language en \
      --use_context session
    ```

    Horizontal text reads well. **Vertical text** (traditional books set top to bottom, right to left) comes out with its columns in the wrong order. Check `source.md` before you translate a vertical scan.

## By system

The route needs no GPU. The device changes the speed, never the text. Install from a clone of the repository; [Installing the PDF extra](../installation-pdf.md) explains every line.

=== "macOS (Apple silicon)"

    ```bash
    pip install ".[pdf]"
    ```

    Install natively; `--device auto` finds MPS and the run prints `PDF extraction device: mps.` Docker cannot reach MPS on a Mac.

    ```bash
    python make_book.py \
      --book_name paper.pdf \
      --to-epub \
      --use_context session
    ```

=== "Linux with NVIDIA"

    ```bash
    pip install ".[pdf]"
    ```

    `--device auto` finds CUDA and the run prints `PDF extraction device: cuda.` To be sure, name it; the run then refuses, with the reason, if it cannot use it:

    ```bash
    python make_book.py \
      --book_name paper.pdf \
      --to-epub \
      --device cuda \
      --use_context session
    ```

=== "Windows with NVIDIA"

    ```bat
    pip install ".[pdf]" ^
        --extra-index-url https://download.pytorch.org/whl/cu126
    ```

    PyPI's Windows wheel is CPU-only, so name PyTorch's CUDA index, and install the NVIDIA driver. Then run as on Linux.

=== "CPU only"

    On Linux, add PyTorch's CPU index (about 380 MB instead of about 3.2 GB of CUDA):

    ```bash
    pip install ".[pdf]" \
        --extra-index-url https://download.pytorch.org/whl/cpu
    ```

    On Windows the plain `pip install ".[pdf]"` already gives the CPU build. The text is the same as on a GPU; extraction is slower (a two-page OCR scan: 26.3 s on the CPU against 10.6 s on Apple silicon's GPU).

    ```bash
    python make_book.py \
      --book_name paper.pdf \
      --to-epub \
      --device cpu \
      --use_context session
    ```

=== "Docker"

    The `pdf` image tag carries Pandoc and the PDF packages. Keep the models in a volume so they download once.

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -v bbm-models:/root/.cache \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:pdf \
      --book_name /book/paper.pdf \
      --to-epub \
      --use_context session
    ```

    With an NVIDIA card on Linux or Windows (WSL2), use the `pdf-cuda` tag (amd64 only) and add `--gpus all`. See [Docker](../docker.md).

## What to expect from any PDF

- Figures stay pictures and their labels are not translated.
- Display equations are pictures cropped from the page: seen where they stood, not translated, not searchable. Inline mathematics is not covered.
- A sentence containing `\s`, `[u](y)` or `<k>` can be refused before translation; escape it in `source.md` and rerun.
- The EPUB always carries the one-line AI-translation credit on this route; `--no_disclosure` is not honored here.
- A PDF is a page description, not a document: a heading one level off or a table that arrives as prose is the format showing through, and a minute's edit in `source.md`.

---

### En Features Session Mode (`docs/en/features/session-mode.md`)

# Session mode

## What it does

`--use_context session` keeps one conversation for the whole book. Every request adds to it, and every request carries it, so the model reads the last chapter or so before it translates the next paragraph. That is how names, register and terminology stay the same from page to page. On an endpoint with prompt caching, the history that was already sent is billed at the cache rate, so a long history costs much less than its size. The other context mode, bare `--use_context` (window mode), re-sends only the last few source/translation pairs with each request.

A history cannot grow for ever. When it reaches `--context-compact-at` (default 8192 estimated tokens, the opening summary included), the model writes a short handoff report, about 300 tokens: the names, the register and the terminology decisions so far. That report opens the next window. The latest one is saved in `<book>_handoff.md`, and `--resume` reads it back. The default was chosen for continuity, not for price; [Session compact budget](../evaluation/session-compact-budget.md) has the measurements, including the fact that a lower value is cheaper.

## Setup

Nothing to install. Session mode needs:

- an EPUB, Markdown or PDF book (a PDF on either route: `--to-epub` translates Markdown, and the text route takes a session too). TXT and SRT never hand context to the model.
- a route that keeps a history: the openai-shaped routes (OpenAI, gateways, groq, xai, litellm, local servers) and anthropic. Gemini and Qwen keep their own history and take bare `--use_context` instead; they refuse `session`. The codex route is a session whether you ask or not: its thread is the history.

The flags:

| flag | what it does |
|---|---|
| `--use_context session` | Turn it on. |
| `--context-compact-at N` | The window budget in estimated tokens, handoff included. Default 8192, minimum 1500. Set it to your model's input limit when that is smaller. |
| `--no-context-compact` | Never ask for a handoff report. The window still rolls over at the budget, but the next one starts empty. |
| `--glossary FILE` | Pin renderings you stand behind (`term -> translation` lines). Only the terms that occur in a request are sent with it. |
| `--glossary-auto on` | Also keep the renderings the handoff reports establish. Off by default. It needs a model that answers with names rather than prose. |

Session mode is refused with `--parallel-workers` (one history cannot be shared between workers) and with `--model_list` (each model has its own cache, and one conversation would be written by several). `--context_paragraph_limit` belongs to window mode and is ignored here.

## Recommended commands

If you translate a novel, add `--use_context session` and, when a recurring name drifts after a window seam, `--glossary names.txt`. If your model runs locally with a small context, set `--context-compact-at` to its input limit. If the endpoint has no prompt cache, use bare `--use_context` instead. The full list, by kind of book, endpoint and system, is on [Recommended settings for EPUB](recommended-epub.md); for a PDF, on [Recommended settings for PDF](recommended-pdf.md).

## What can go wrong

- **`session: compacting at 8192 estimated tokens (the default; --context-compact-at overrides)`**. Not a problem. The run says the budget it uses.
- **`cached=` on the progress bar stays at 0 after a dozen requests.** The endpoint has no prompt cache, and every request pays for the whole history at full price. Press Ctrl+C and rerun with bare `--use_context`.
- **`--use_context session outside plan mode leaves grouping off, so every paragraph is its own request and each one re-reads the whole history.`** An EPUB outside plan mode (for example `--plan-classify none`), or an SRT book on the codex route. Raise `--accumulated_num`. Markdown and PDF runs do not print this; there, a larger `--batch_size` sends fewer requests.
- **`Error: --use_context session is not implemented for the gemini format; it would be accepted and ignored.`** Use bare `--use_context` on Gemini and Qwen.
- **`--use_context session is not supported for txt books; it will be ignored.`** TXT and SRT carry no context.
- **`--parallel-workers is not supported with --use_context session: one history is the context, and a worker cannot share it.`** Choose one. Bare `--use_context` keeps the workers.
- **`a compact budget of N is too small for a session; use at least 1500 estimated tokens.`** Below 1500 a window is mostly the handoff that opens it. Use bare `--use_context` if you want less context than that.
- **`ℹ handoff report failed (…); starting the next context window without a summary`**, or `… keeping the current context and retrying on the next paragraph`. One compaction produced no usable report. Translation continues. If the text after that seam drifts, pin the terms with `--glossary`.
- **`--glossary-auto on learns renderings from the handoff report a session writes when it compacts, and this run keeps no session to compact.`** Add `--use_context session`, or use `--glossary`, which needs no session.
- **The text drifts after a compaction on a small model.** The handoff is written by the model, and a small model can write a poor one. Pin the names with `--glossary`, or pass `--no-context-compact` and accept an empty window at each seam.
- **`--context-compact-at` or `--no-context-compact` printed as `only applies to --use_context session; ignoring it.`** You gave a compaction flag without a session.

---

### En Formats Epub (`docs/en/formats/epub.md`)

# EPUB

If you want a bilingual edition of an EPUB, this is the command most people need:

```bash
bbook_maker --book_name my_book.epub --language zh-hans --use_context session --quiet
```

It writes `my_book_bilingual.epub` beside the input. [Recommended settings for EPUB](../features/recommended-epub.md) has what to change for a textbook, a small model or a local server.

EPUB is the format this tool knows best. Every feature works on it.

## How the file is read

An EPUB is a zip of XHTML pages. The tool reads every page the book's spine lists.

- **With an LLM route (the default):** the book goes through [plan mode](../features/plan-mode.md). The loader finds every block that carries text (paragraphs, headings, list items, table cells, blockquotes, verse lines, captions), groups them by kind, and asks the model which kinds are worth translating. The answer is saved in `<book>_plan.json` and reused by later runs. Consecutive blocks then share one request, up to a token budget and a unit cap.
- **With a translation-service route and no `--classify-model`, or with `--plan-classify none`:** only the `--translate-tags` selection is translated, `<p>` by default. Verse or a table outside `<p>` then stays in the source language.
- Links, emphasis and other inline markup inside a paragraph are replaced by numbered markers before translation and put back afterwards, so the model never sees raw HTML.
- Content inside `--exclude-translate-tags` (`sup` and `code` by default) is never sent.

## What you get

- `<name>_bilingual.epub` beside the input. Each translation sits right after its original, with the same tag and class. Ids and internal links are kept.
- A one-line credit below the book's intro: "Translated by \<model\>, \<year\>." `--no_disclosure` leaves it out.
- `bbm_translation_metadata.json` inside the book on plan and session runs: the model, the date, and the checksum of a `--glossary` file.
- `<book>_plan.json` beside the input (the plan), and `<book>_handoff.md` on a session run (the latest handoff report).
- On Ctrl+C or an error: `<name>_bilingual_temp.epub` and a checkpoint. Rerun with `--resume`.

## Recommended command

```bash
bbook_maker \
  --book_name my_book.epub \
  --language zh-hans \
  --use_context session \
  --quiet
```

Preview the plan first on an unusual book (a textbook, a bilingual edition, a book whose body is not in `<p>`). It needs no key and translates nothing:

```bash
bbook_maker \
  --book_name my_book.epub \
  --plan-dry-run
```

## Flags that apply

### Route and run

These work the same on every format.

| flag | what it does |
|---|---|
| `--book_name PATH` | The file to translate. The extension picks the format. |
| `-m`, `--model MODEL` | The model id, exactly as the endpoint names it. Default `gpt-6-luna` on the openai format. |
| `--key KEY` | API key; several comma-separated keys rotate. Falls back to `BBM_API_KEY`, then the format's own variable. |
| `--api_base URL` | The endpoint. Defaults to the format's official host. |
| `--api_format FORMAT` | The API the endpoint speaks, or a translation service. Inferred from `--api_base` when left out. |
| `--provider NAME` | A named endpoint from `bbm_providers.json`. |
| `--model_list IDS` | Several models to rotate across. Refused with `--use_context session`. |
| `--language LANGUAGE` | Target language: a tag, a name, or `TAG:NAME`. Default `zh-hans`. |
| `--source_lang LANGUAGE` | Source language, stated. Reaches every LLM prompt; sent as a field on `qwen` and `customapi`. |
| `--prompt VALUE_OR_FILE` | Custom prompt: `user` template (must contain `{text}`), `system`, `style`. |
| `--temperature FLOAT` | Sampling temperature, on the formats that take one. |
| `--no-thinking` | Ask the model not to reason before answering. The field is negotiated on the OpenAI-shaped routes; `thinking: disabled` on anthropic; refused on codex. |
| `--extra_body JSON` | Extra request-body fields on the openai and anthropic routes. |
| `--extra_headers JSON` | Extra HTTP headers on the openai and anthropic routes. |
| `--interval SECONDS` | Pause between requests. Only the gemini format uses it. |
| `-p`, `--proxy URL` | HTTP proxy for the run. |
| `--test` | Translate only the first paragraphs. |
| `--test_num N` | How many, with `--test` (default 10). |
| `--resume` | Continue an interrupted run from its checkpoint. |
| `--single_translate` | Write the translation only, without the original. |

### EPUB flags

| flag | what it does |
|---|---|
| `--translate-tags TAGS` | Tags to translate when there is no plan (default `p`). Ignored in plan mode. |
| `--exclude-translate-tags TAGS` | Tags whose content is never translated (default `sup,code`; `""` clears it). |
| `--allow_navigable_strings` | Also translate text that sits outside any tag. Redundant in plan mode. |
| `--only_filelist FILES` | Translate only these internal files (OPF-relative names, comma-separated). |
| `--exclude_filelist FILES` | Skip these internal files. Ignored when `--only_filelist` is given. |
| `--plan-classify MODE` | How the plan is decided: `auto` (default), `none`, `all`, `model`, `agent`. See [Plan mode](../features/plan-mode.md). |
| `--classify-model MODEL` | Classify with another model (default: the provider entry's `classify_model`, else the translating model). Typed, it puts the run in `model` mode, so a failure stops the run; it also gives a translation-service route a plan. `--plan-classify-model` is the old name. Ignored under `--plan-classify all` or `agent`. |
| `--classify-base-url URL` | Where that model is served, when it is not the run's endpoint (OpenAI-compatible only). |
| `--classify-key KEY` | The key for `--classify-base-url`. See [which key goes where](../providers.md#which-key-goes-where). |
| `--plan-dry-run` | Print the plan and write `<book>_plan.json` without translating. No key needed. |
| `--plan-min-coverage FRACTION` | Stop when the plan covers less than this share of the text (default 0.5). |
| `--poetry-group-size N` | Deprecated; still works and warns. Use `--max-batch-units`. |
| `--accumulated_num N` | In plan mode, the token budget per request (derived when unset; `1` turns grouping off). Without a plan, characters accumulated per request. |
| `--max-batch-units N` | Plan mode: the most units in one request (default 16; 8 on an endpoint without a strict schema). |
| `--block_size N` | Without a plan: merge paragraphs into delimiter-translated blocks. Ignored while `--accumulated_num` is above 1. |
| `--sentence_mode` | Translate sentence by sentence. Not with a plan; ignored while `--accumulated_num` is above 1. |
| `--use_context [window\|session]` | Carry earlier paragraphs: bare or `window` re-sends a few pairs; `session` keeps one history. See [Session mode](../features/session-mode.md). |
| `--context_paragraph_limit N` | Window mode: how many pairs to re-send. |
| `--context-compact-at N` | Session mode: compact the history at this many estimated tokens (default 8192, minimum 1500). |
| `--no-context-compact` | Session mode: roll over without a handoff report. |
| `--glossary FILE` | `term -> translation` pins, sent with the requests they occur in. openai-shaped and codex routes. `--terminology` is the same flag. |
| `--glossary-auto on\|off` | Keep the renderings a session's handoff reports establish. Off unless asked for. |
| `--parallel-workers N` | Translate several chapters at once (2 to 4 is useful). Refused with a session and on codex. |
| `--translation_style CSS` | CSS for the translated paragraphs. |
| `--translation_color COLOR` | Color only; `--translation_style` wins. |
| `--no_disclosure` | Leave out the one-line AI-translation credit, and the metadata file. |
| `--translation-metadata` | Write `bbm_translation_metadata.json` into a plain tag-mode run (plan and session runs write it anyway). |
| `--retranslate OUT FILE START END` | Retranslate a range of an existing bilingual EPUB. |
| `--quiet` | No progress bars or paragraph echoes; reports and errors still print. |

## Not for this format

- `--batch_size`: the EPUB loader groups with `--accumulated_num`; the run warns.
- `--batch`, `--batch-use`: refused on EPUB: the Batch API path is never reached there.
- `--to-epub`, `--pdf-ocr`, `--ocr-replace-layer`, `--device`, `--ocr-lang`, `--pages`, `--no-formula-images`, `--pdf_layout`, `--img-model`, `--img-base-url`, `--img-key`: PDF only. `--to-epub` on an EPUB stops the run; the others warn or do nothing (the three image flags: the run warns that only the PDF route has an image step).

---

### En Formats Md (`docs/en/formats/md.md`)

# Markdown

A Markdown file is translated prose block by prose block. Code, tables and front matter are kept as they are.

## How the file is read

- YAML front matter, fenced code blocks, tables and lines that carry no prose are passed through untranslated.
- Headings and paragraphs are translation blocks.
- Blocks are sent in batches of `--batch_size` (default 10), and a batch also stops at about 2000 characters. The heading path above a batch goes with it as a breadcrumb, so the model knows which section it is in.
- `--use_context` works here, in both window and session mode.

This is also the loader behind [PDF to bilingual EPUB](../features/pdf-to-epub.md): a PDF is first turned into Markdown, then translated here.

## What you get

- `<name>_bilingual.md` beside the input: each block followed by its translation. With `--single_translate`, the translation only.
- On a session run, `<name>_handoff.md` with the latest handoff report.
- On Ctrl+C or an error: `<name>_bilingual_temp.txt` and a checkpoint. Rerun with `--resume`.

## Recommended command

```bash
bbook_maker \
  --book_name my_doc.md \
  --language zh-hans \
  --prompt prompt_md.json \
  --use_context session \
  --batch_size 20
```

`prompt_md.json` in the repository is a prompt written for Markdown: it asks the model to keep the markup. There is no plan mode for Markdown, so in session mode every request re-reads the history. Raise `--batch_size` to send fewer, larger requests.

## Flags that apply

### Route and run

These work the same on every format.

| flag | what it does |
|---|---|
| `--book_name PATH` | The file to translate. The extension picks the format. |
| `-m`, `--model MODEL` | The model id, exactly as the endpoint names it. Default `gpt-6-luna` on the openai format. |
| `--key KEY` | API key; several comma-separated keys rotate. Falls back to `BBM_API_KEY`, then the format's own variable. |
| `--api_base URL` | The endpoint. Defaults to the format's official host. |
| `--api_format FORMAT` | The API the endpoint speaks, or a translation service. Inferred from `--api_base` when left out. |
| `--provider NAME` | A named endpoint from `bbm_providers.json`. |
| `--model_list IDS` | Several models to rotate across. Refused with `--use_context session`. |
| `--language LANGUAGE` | Target language: a tag, a name, or `TAG:NAME`. Default `zh-hans`. |
| `--source_lang LANGUAGE` | Source language, stated. Reaches every LLM prompt; sent as a field on `qwen` and `customapi`. |
| `--prompt VALUE_OR_FILE` | Custom prompt: `user` template (must contain `{text}`), `system`, `style`. |
| `--temperature FLOAT` | Sampling temperature, on the formats that take one. |
| `--no-thinking` | Ask the model not to reason before answering. The field is negotiated on the OpenAI-shaped routes; `thinking: disabled` on anthropic; refused on codex. |
| `--extra_body JSON` | Extra request-body fields on the openai and anthropic routes. |
| `--extra_headers JSON` | Extra HTTP headers on the openai and anthropic routes. |
| `--interval SECONDS` | Pause between requests. Only the gemini format uses it. |
| `-p`, `--proxy URL` | HTTP proxy for the run. |
| `--test` | Translate only the first paragraphs. |
| `--test_num N` | How many, with `--test` (default 10). |
| `--resume` | Continue an interrupted run from its checkpoint. |
| `--single_translate` | Write the translation only, without the original. |

### Markdown flags

| flag | what it does |
|---|---|
| `--batch_size N` | Prose blocks sent in one request (default 10; a batch also stops at about 2000 characters). |
| `--use_context [window\|session]` | Carry earlier paragraphs: bare or `window` re-sends a few pairs; `session` keeps one history. |
| `--context_paragraph_limit N` | Window mode: how many pairs to re-send. |
| `--context-compact-at N` | Session mode: compact at this many estimated tokens (default 8192, minimum 1500). |
| `--no-context-compact` | Session mode: roll over without a handoff report. |
| `--glossary FILE` | `term -> translation` pins (openai-shaped and codex routes). `--terminology` is the same flag. |
| `--glossary-auto on\|off` | Keep the renderings a session's handoff reports establish. Off unless asked for. |
| `--parallel-workers N` | Translate several batches or sections at once. Refused with a session and on codex. |

## Not for this format

- `--accumulated_num`: not read by the Markdown loader; the run warns and points at `--batch_size`. Outside plan mode a session re-reads its history on every request, so a larger `--batch_size` keeps the request count down.
- `--max-batch-units`, `--plan-classify`, `--plan-dry-run`, `--plan-min-coverage`, `--poetry-group-size`: plan mode is EPUB only.
- `--classify-model`, `--classify-base-url`, `--classify-key` (and `--plan-classify-model`, the old name): nothing classifies on this format yet. The run warns that the flag is ignored.
- `--exclude-translate-tags`: accepted without a warning, but the Markdown loader does not read it.
- `--translate-tags`, `--allow_navigable_strings`: EPUB markup selectors; the run warns.
- `--only_filelist`, `--exclude_filelist`: EPUB internal files; ignored.
- `--block_size`, `--sentence_mode`: EPUB only; ignored.
- `--translation_style`, `--translation_color`: Markdown carries no style; the run warns.
- `--no_disclosure`, `--translation-metadata`: the output carries no credit line and no metadata file; the run warns.
- `--retranslate`: EPUB only; refused.
- `--quiet`: EPUB only; the run warns.
- `--batch`, `--batch-use`: the Markdown loader does not implement the Batch API.
- `--to-epub`, `--pdf-ocr`, `--ocr-replace-layer`, `--device`, `--ocr-lang`, `--pages`, `--no-formula-images`, `--pdf_layout`, `--img-model`, `--img-base-url`, `--img-key`: PDF only. `--to-epub` on this format stops the run; the others warn or do nothing (the three image flags: the run warns that only the PDF route has an image step).

---

### En Formats Pdf (`docs/en/formats/pdf.md`)

# PDF

A PDF can take two routes. Pick the first unless you want plain text.

| route | flag | output | needs |
|---|---|---|---|
| **PDF to bilingual EPUB** | `--to-epub` | a reflowable EPUB with a table of contents, figures and display formulas as pictures | the [PDF extra](../installation-pdf.md) and Pandoc 3.1.12+ |
| **Text route** (older) | none | a bilingual `.txt`, plus an attempted EPUB | the base install |

## The `--to-epub` route

### How the file is read

1. [docling](https://github.com/docling-project/docling) reads the PDF's text layer with its layout and table models and writes Markdown: `<name>_book/source.md` and the images.
2. Pages with no text layer (a scan) are refused unless you pass `--pdf-ocr`; then the OCR models read them. A page that has a text layer keeps it, unless you also pass `--ocr-replace-layer`: then every page is OCR'd and the PDF's own layer is not used.
3. With `--img-model`, a vision model corrects the roles docling gave the page's regions (heading, caption, footnote, code) from the page image.
4. Heading levels are read from the page (numbering first, then font size and weight). Display formulas are cropped from the page as pictures.
5. The [Markdown loader](md.md) translates `source.md` into `book_bilingual.md`.
6. Pandoc builds the EPUB. Its navigation follows the headings.

A rerun of the same command reuses the extraction and a finished translation. Edit `source.md` before the translation runs if something came out wrong. Delete `book_bilingual.md` to translate again. [PDF to bilingual EPUB](../features/pdf-to-epub.md) has the recommended commands and the terminal lines to watch for.

### What you get

- `<name>_book/` beside the PDF: `source.md`, `images/`, `book_bilingual.md`, a manifest, and `.work/`.
- `<name>_bilingual.epub` beside the PDF, a copy of the finished book.
- In `source.md`, a `` comment where each page starts, numbered as in the PDF file (from 1, as `--pages` counts), empty pages included. Items docling gave no page, which is rare, come last, under ``; the run says how many.
- With `--pages 12-30`: `<name>_pages-12-30_book/` and `<name>_pages-12-30_bilingual.epub`, so a chapter never overwrites the whole book.

### Flags that apply

The route's own flags:

| flag | what it does |
|---|---|
| `--to-epub` | Take this route. |
| `--pdf-ocr` | Read pages with no text layer with the OCR models. Off by default; they are refused without it. |
| `--ocr-replace-layer` | With `--pdf-ocr`: OCR every page and use that text instead of the PDF's text layer. Off by default: a layer is kept and only pages without one are OCR'd. For a layer that is wrong (another language, garbage); on a clean scan the layer reads better. Toggling it reads the PDF again. |
| `--ocr-lang LANGS` | With `--pdf-ocr`: the languages the OCR engine reads, comma-separated, in the engine's own codes (rapidocr: `ch`, `en`, `latin`; easyocr: `ch_sim`, `ja`, `ko`; ocrmac: `zh-Hans`, `ja-JP`), or a BCP-47 tag behind `iso:` (`iso:zh`, `iso:ja`), which every engine accepts. rapidocr uses only the first language and refuses `ch_sim`: write `iso:zh` for Chinese. |
| `--device auto\|cpu\|cuda\|mps\|xpu` | Where the extraction models run. `auto` (default) detects an accelerator and falls back to the CPU. The CPU gives the same output, slower. |
| `--pages PAGES` | Only these pages, numbered from 1 (`12-30`, `1,3,5-7`). |
| `--no-formula-images` | Leave display formulas as `` placeholders instead of pictures. |
| `--img-model MODEL` | A vision model that corrects region roles from the page image. Off unless named here or as the provider entry's `img_model` (the shipped `openai` entry names one); `none` turns that off. Never the translating model by fallback. About 3,000 prompt tokens per page. |
| `--img-base-url URL` | Where that model is served, when it is not the run's endpoint (OpenAI-compatible only). |
| `--img-key KEY` | The key for `--img-base-url`; defaults to the run's key on the run's own endpoint. |
| `--quiet` | No live progress line during extraction. |

Every other flag goes to the Markdown translation unchanged. These are the ones that do something there:

| flag | what it does |
|---|---|
| `--book_name PATH` | The file to translate. The extension picks the format. |
| `-m`, `--model MODEL` | The model id, exactly as the endpoint names it. Default `gpt-6-luna` on the openai format. |
| `--key KEY` | API key; several comma-separated keys rotate. Falls back to `BBM_API_KEY`, then the format's own variable. |
| `--api_base URL` | The endpoint. Defaults to the format's official host. |
| `--api_format FORMAT` | The API the endpoint speaks, or a translation service. Inferred from `--api_base` when left out. |
| `--provider NAME` | A named endpoint from `bbm_providers.json`. |
| `--model_list IDS` | Several models to rotate across. Refused with `--use_context session`. |
| `--language LANGUAGE` | Target language: a tag, a name, or `TAG:NAME`. Default `zh-hans`. |
| `--source_lang LANGUAGE` | Source language, stated. Reaches every LLM prompt; sent as a field on `qwen` and `customapi`. |
| `--prompt VALUE_OR_FILE` | Custom prompt: `user` template (must contain `{text}`), `system`, `style`. |
| `--temperature FLOAT` | Sampling temperature, on the formats that take one. |
| `--no-thinking` | Ask the model not to reason before answering. The field is negotiated on the OpenAI-shaped routes; `thinking: disabled` on anthropic; refused on codex. |
| `--extra_body JSON` | Extra request-body fields on the openai and anthropic routes. |
| `--extra_headers JSON` | Extra HTTP headers on the openai and anthropic routes. |
| `--interval SECONDS` | Pause between requests. Only the gemini format uses it. |
| `-p`, `--proxy URL` | HTTP proxy for the run. |
| `--test` | Translate only the first paragraphs. |
| `--test_num N` | How many, with `--test` (default 10). |
| `--resume` | Continue an interrupted run from its checkpoint. |
| `--single_translate` | Write the translation only, without the original. |
| `--batch_size N` | Prose blocks per request (default 10). |
| `--use_context [window\|session]` | Carry earlier text. `session` is recommended: a PDF extracts into many short blocks. |
| `--context_paragraph_limit N` | Window mode: pairs to re-send. |
| `--context-compact-at N` | Session mode: compact at this many estimated tokens (default 8192, minimum 1500). |
| `--no-context-compact` | Session mode: roll over without a handoff report. |
| `--glossary FILE` | `term -> translation` pins (openai-shaped and codex routes). |
| `--glossary-auto on\|off` | Keep renderings from handoff reports. Learns only when a compaction happens, so a short paper at the default budget learns nothing. |
| `--parallel-workers N` | Several batches at once. Refused with a session. |

`--with-ocr` and `--no-gpu`, the route's old spellings, still work as `--pdf-ocr` and `--device cpu` for one release and print a notice. They are not in `--help`.

### Not for the `--to-epub` route

- `--no_disclosure`: not honored yet; the credit line is always added.
- `--translation-metadata`: Pandoc builds this EPUB, which carries no metadata file and no embedded glossary.
- `--pdf_layout`: belongs to the text route.
- `--accumulated_num`, `--max-batch-units` and the plan flags (`--plan-classify`, `--plan-dry-run`, `--plan-min-coverage`): plan mode is EPUB only; the Markdown loader groups with `--batch_size`.
- `--classify-model`, `--classify-base-url`, `--classify-key`: nothing on this route classifies yet. The run warns that the flag is ignored.
- `--translate-tags`, `--exclude-translate-tags`, `--allow_navigable_strings`, `--only_filelist`, `--exclude_filelist`, `--block_size`, `--sentence_mode`, `--translation_style`, `--translation_color`, `--retranslate`: EPUB input only.
- `--batch`, `--batch-use`: not implemented on this route.

## The text route (no `--to-epub`)

### How the file is read

The text of every page is taken with PyMuPDF and split into lines. Lines are sent in groups of `--batch_size` (default 10), with earlier text when `--use_context` is on (window or `session`) and the pins of a `--glossary`. There is no structure: no headings, no figures, no tables.

### What you get

- `<name>_bilingual.txt` beside the PDF.
- An attempt at `<name>_bilingual.epub` from the same text. If it fails, the TXT remains, so nothing is translated twice.
- With `--pdf_layout`: `<name>_bilingual_<layout>.pdf` for `top-bottom`, `side-by-side`, or both with `all`. This needs the `reportlab` package, which is not in the requirements; without it the run prints `pdf creation skipped: install reportlab first` and goes on.
- On Ctrl+C or an error: `<name>_bilingual_temp.txt`. Rerun with `--resume`.

```bash
bbook_maker \
  --book_name paper.pdf \
  --language zh-hans \
  --batch_size 20
```

### Flags that apply

The route and run flags in the table above, plus:

| flag | what it does |
|---|---|
| `--batch_size N` | Lines per request (default 10). |
| `--pdf_layout none\|top-bottom\|side-by-side\|all` | Also write a bilingual PDF in this layout (needs `reportlab`). |

### Not for the text route

- `--parallel-workers`: the run stays serial. The run warns.
- `--pdf-ocr`, `--ocr-replace-layer`, `--device`, `--ocr-lang`, `--pages`, `--no-formula-images`: `--to-epub` only. The run warns and reads the whole file.
- `--img-model`, `--img-base-url`, `--img-key`: the image step belongs to `--to-epub`. The run warns.
- `--classify-model` and its two companions: nothing classifies here. The run warns.
- Every EPUB-only flag listed for the `--to-epub` route above, and `--accumulated_num`, `--quiet`, `--no_disclosure`, `--translation-metadata`.

---

### En Formats Srt (`docs/en/formats/srt.md`)

# SRT

Subtitles are translated block by block. Each block keeps its number and its timeline.

## How the file is read

- The file is split into blocks at blank lines. A block is a number, a timeline and one or more lines of text.
- By default each block is its own request. `--accumulated_num N` puts consecutive blocks into one request, up to `N` characters (capped at 512).
- The loader's own prompt tells the model to leave each block's number and timeline alone. If you replace the `user` template with `--prompt`, say that in your template too, or the answer will not parse as SRT. The run warns about this.

## What you get

- `<name>_bilingual.srt` beside the input. Each block keeps its number and timeline, with the original text followed by the translation. With `--single_translate`, the translation only.
- On Ctrl+C or an error: `<name>_bilingual_temp.srt` and a checkpoint. Rerun with `--resume`.

## Recommended command

```bash
bbook_maker \
  --book_name test_books/Lex_Fridman_episode_322.srt \
  --language zh-hans \
  --accumulated_num 400
```

## Flags that apply

### Route and run

These work the same on every format.

| flag | what it does |
|---|---|
| `--book_name PATH` | The file to translate. The extension picks the format. |
| `-m`, `--model MODEL` | The model id, exactly as the endpoint names it. Default `gpt-6-luna` on the openai format. |
| `--key KEY` | API key; several comma-separated keys rotate. Falls back to `BBM_API_KEY`, then the format's own variable. |
| `--api_base URL` | The endpoint. Defaults to the format's official host. |
| `--api_format FORMAT` | The API the endpoint speaks, or a translation service. Inferred from `--api_base` when left out. |
| `--provider NAME` | A named endpoint from `bbm_providers.json`. |
| `--model_list IDS` | Several models to rotate across. Refused with `--use_context session`. |
| `--language LANGUAGE` | Target language: a tag, a name, or `TAG:NAME`. Default `zh-hans`. |
| `--source_lang LANGUAGE` | Source language, stated. Reaches every LLM prompt; sent as a field on `qwen` and `customapi`. |
| `--prompt VALUE_OR_FILE` | Custom prompt: `user` template (must contain `{text}`), `system`, `style`. |
| `--temperature FLOAT` | Sampling temperature, on the formats that take one. |
| `--no-thinking` | Ask the model not to reason before answering. The field is negotiated on the OpenAI-shaped routes; `thinking: disabled` on anthropic; refused on codex. |
| `--extra_body JSON` | Extra request-body fields on the openai and anthropic routes. |
| `--extra_headers JSON` | Extra HTTP headers on the openai and anthropic routes. |
| `--interval SECONDS` | Pause between requests. Only the gemini format uses it. |
| `-p`, `--proxy URL` | HTTP proxy for the run. |
| `--test` | Translate only the first paragraphs. |
| `--test_num N` | How many, with `--test` (default 10). |
| `--resume` | Continue an interrupted run from its checkpoint. |
| `--single_translate` | Write the translation only, without the original. |

### SRT flags

| flag | what it does |
|---|---|
| `--accumulated_num N` | Characters of subtitle blocks sent in one request (capped at 512 for SRT). |

## Not for this format

- `--batch_size`: not read by the SRT loader; group with `--accumulated_num`.
- `--use_context`: the SRT loader never hands context to the model, in either mode; the run warns.
- `--context_paragraph_limit`, `--context-compact-at`, `--no-context-compact`: context flags; nothing reads them here.
- `--glossary`, `--glossary-auto`: forwarded by the EPUB, Markdown and PDF loaders only; the run warns.
- `--max-batch-units`, `--plan-classify`, `--plan-dry-run`, `--plan-min-coverage`, `--poetry-group-size`: plan mode is EPUB only.
- `--classify-model`, `--classify-base-url`, `--classify-key` (and `--plan-classify-model`, the old name): nothing classifies on this format yet. The run warns that the flag is ignored.
- `--translate-tags`, `--exclude-translate-tags`, `--allow_navigable_strings`: EPUB markup selectors; the run warns.
- `--only_filelist`, `--exclude_filelist`: EPUB internal files; ignored.
- `--block_size`, `--sentence_mode`: EPUB only; ignored.
- `--parallel-workers`: the SRT run stays serial; the run warns.
- `--translation_style`, `--translation_color`: subtitles carry no style; the run warns.
- `--no_disclosure`, `--translation-metadata`: the output carries no credit line and no metadata file; the run warns.
- `--retranslate`: EPUB only; refused.
- `--quiet`: EPUB only; the run warns.
- `--batch`, `--batch-use`: the SRT loader does not implement the Batch API.
- `--to-epub`, `--pdf-ocr`, `--ocr-replace-layer`, `--device`, `--ocr-lang`, `--pages`, `--no-formula-images`, `--pdf_layout`, `--img-model`, `--img-base-url`, `--img-key`: PDF only. `--to-epub` on this format stops the run; the others warn or do nothing (the three image flags: the run warns that only the PDF route has an image step).

---

### En Formats Txt (`docs/en/formats/txt.md`)

# TXT

```bash
bbook_maker --book_name my_book.txt --language zh-hans --batch_size 20
```

That writes `my_book_bilingual.txt` beside the input. SRT subtitles and Markdown have pages of their own below; a PDF has [its own section](../features/pdf-to-epub.md).

A plain-text file is translated line group by line group. There is no structure to keep, so there are few options.

## How the file is read

- The file is read as UTF-8 and split into lines.
- Lines are sent in groups of `--batch_size` (default 10), joined by newlines.
- A group that is empty, whitespace or only a number is copied without a request.
- No earlier text is sent with a request: the TXT loader has no context mode.

## What you get

- `<name>_bilingual.txt` beside the input: each group of original lines followed by its translation. With `--single_translate`, the translation only.
- On Ctrl+C or an error: `<name>_bilingual_temp.txt` and a checkpoint. Rerun with `--resume`.

## Recommended command

```bash
bbook_maker \
  --book_name test_books/the_little_prince.txt \
  --language zh-hans \
  --batch_size 20
```

A larger `--batch_size` means fewer requests and more context per request. Lower it if the model starts merging or dropping lines.

## Flags that apply

### Route and run

These work the same on every format.

| flag | what it does |
|---|---|
| `--book_name PATH` | The file to translate. The extension picks the format. |
| `-m`, `--model MODEL` | The model id, exactly as the endpoint names it. Default `gpt-6-luna` on the openai format. |
| `--key KEY` | API key; several comma-separated keys rotate. Falls back to `BBM_API_KEY`, then the format's own variable. |
| `--api_base URL` | The endpoint. Defaults to the format's official host. |
| `--api_format FORMAT` | The API the endpoint speaks, or a translation service. Inferred from `--api_base` when left out. |
| `--provider NAME` | A named endpoint from `bbm_providers.json`. |
| `--model_list IDS` | Several models to rotate across. Refused with `--use_context session`. |
| `--language LANGUAGE` | Target language: a tag, a name, or `TAG:NAME`. Default `zh-hans`. |
| `--source_lang LANGUAGE` | Source language, stated. Reaches every LLM prompt; sent as a field on `qwen` and `customapi`. |
| `--prompt VALUE_OR_FILE` | Custom prompt: `user` template (must contain `{text}`), `system`, `style`. |
| `--temperature FLOAT` | Sampling temperature, on the formats that take one. |
| `--no-thinking` | Ask the model not to reason before answering. The field is negotiated on the OpenAI-shaped routes; `thinking: disabled` on anthropic; refused on codex. |
| `--extra_body JSON` | Extra request-body fields on the openai and anthropic routes. |
| `--extra_headers JSON` | Extra HTTP headers on the openai and anthropic routes. |
| `--interval SECONDS` | Pause between requests. Only the gemini format uses it. |
| `-p`, `--proxy URL` | HTTP proxy for the run. |
| `--test` | Translate only the first paragraphs. |
| `--test_num N` | How many, with `--test` (default 10). |
| `--resume` | Continue an interrupted run from its checkpoint. |
| `--single_translate` | Write the translation only, without the original. |

### TXT flags

| flag | what it does |
|---|---|
| `--batch_size N` | Lines sent in one request (default 10). |

## Not for this format

- `--use_context`: the TXT loader never hands context to the model, in either mode. The run warns; for a session it prints `--use_context session is not supported for txt books; it will be ignored.`
- `--context_paragraph_limit`, `--context-compact-at`, `--no-context-compact`: context flags; nothing reads them here.
- `--glossary`, `--glossary-auto`: forwarded by the EPUB, Markdown and PDF loaders only; the run warns.
- `--accumulated_num`: not read by the TXT loader; the run warns and points at `--batch_size`.
- `--max-batch-units`, `--plan-classify`, `--plan-dry-run`, `--plan-min-coverage`, `--poetry-group-size`: plan mode is EPUB only.
- `--classify-model`, `--classify-base-url`, `--classify-key` (and `--plan-classify-model`, the old name): nothing classifies on this format yet. The run warns that the flag is ignored.
- `--translate-tags`, `--exclude-translate-tags`, `--allow_navigable_strings`: EPUB markup selectors; the run warns.
- `--only_filelist`, `--exclude_filelist`: EPUB internal files; ignored.
- `--block_size`, `--sentence_mode`: EPUB only; ignored.
- `--parallel-workers`: the TXT run stays serial; the run warns.
- `--translation_style`, `--translation_color`: plain text carries no style; the run warns.
- `--no_disclosure`, `--translation-metadata`: the output carries no credit line and no metadata file; the run warns.
- `--retranslate`: EPUB only; refused.
- `--quiet`: EPUB only; the run warns.
- `--batch`, `--batch-use`: the TXT loader does not implement the Batch API.
- `--to-epub`, `--pdf-ocr`, `--ocr-replace-layer`, `--device`, `--ocr-lang`, `--pages`, `--no-formula-images`, `--pdf_layout`, `--img-model`, `--img-base-url`, `--img-key`: PDF only. `--to-epub` on this format stops the run; the others warn or do nothing (the three image flags: the run warns that only the PDF route has an image step).

---

### Zh Evaluation Grouping Batch Size (`docs/zh/evaluation/grouping-batch-size.md`)

# 每次请求的单元数与 token 数：`--max-batch-units`、`--accumulated_num`

## 摘要

计划模式把若干单元（段落、诗行、单元格）放进一个请求。一个请求受两个上限约束：单元上限（`--max-batch-units`）和 token 预算（`--accumulated_num`）。两项付费研究考察了请求从哪里开始出错。一项在五个端点上做了 210 个请求的研究，发现所有请求级错误都出在 50 单元那一档。一项 923 个请求的扫描实验发现，第一个内容错误出现在每请求 64 个有效单元处，且只出现在文学散文上；在 1600 到 4800 token 的预算范围内，任何地方都没有错误。随附的默认值远低于这两者：16 个单元、1200 到 1600 token 的预算，在不校验严格 JSON schema 的端点上减半。**这些默认值是所有者设定的安全余量，不是测出来的最优值。** 测量表明，请求越大，每个译出的词越便宜；所有者选择了余量，放弃了这部分节省。

## 实验设置

**退化研究（2026 年 9 月 4 日）。** 210 个请求，5 个实验组，请求大小为 500、1000、2000、4000 和 8000 字符，每个请求最多 50 个单元。每种大小下各组的批次逐字节相同，由该分支自己的计划器从 childrens-literature、moby-dick、jlreq-in-english 和 mahabharata 构建。实验组：OpenAI 端点上的 `gpt-4o-mini` 和 `gpt-5.6-luna`，经转售商访问的 luna 和 `claude-sonnet-5`，以及经 OpenRouter 访问的 `deepseek-v4-flash`。错误按固定规则判定：尾部偷懒压缩、截断、数字保留。

**错误出现扫描（2026 年 9 月 5 日）。** Codex 路线上 76 个有效单元格、923 个请求：46 个预定单元格，加上一个 30 单元格的扩展（扩到 64、96 和 128 个单元）——因为发现这条路线自己的减半机制把网格限制在了 24 个有效单元。四本 epub-samples 书：childrens-literature（文学散文）、moby-dick-mo（长篇小说）、epub30-spec（短小的技术单元）和 wasteland（诗歌）。两个模型：`gpt-5.4-mini` 和 Codex 默认模型。每个单元格都从生成的 EPUB 中回读检查。内容错误指译文放错位置、译文缺失，或文本中残留标记。

**弱模型重跑**，在 `gpt-4o-mini` 和 `deepseek-chat` 上重跑了两个维度。它来自 2026 年 9 月的分组报告及下面的两张图；其逐单元格表格没有保留。

## 结果

退化研究，各组最大的无错请求大小：

| 实验组 | 最大无错档位 | 修正后的说明 |
|---|---|---|
| off-mini（官方 4o-mini） | 1000 | 2000 及以上有真实错误（见下） |
| off-luna（官方 luna） | **8000，全部无错** | 它触发的规则 4 是中文数字导致的误报 |
| rtr-luna（nianhuaapi luna） | **8000，全部无错** | 与官方唯一的差别：延迟为 2.4–6× |
| rtr-sonnet（nianhuaapi claude-sonnet-5） | 无 | 0/30 返回合法 schema 的 JSON——是传输格式问题，不是翻译问题 |
| or-deepseek（OpenRouter deepseek-v4-flash） | ~4000 | 另有原文回显错误（见下），补全 token 消耗 6.6×，最慢 |

- 尾部偷懒压缩从未触发（最差 88%，阈值 70%）。210 个请求中零截断。39 处未匹配的数字串里有 36 处是正确的中文数字。
- **rtr-sonnet 之外的所有请求级错误都出在 50 单元那一档。** 起约束作用的是单元数，而不是字符预算。
- 一次无声的尾部错位：`gpt-4o-mini`，8000 字符，50 个引文/出处交替的单元。ID 按顺序返回，但大约第 44 到 49 个位置的译文错位了一格。ID 回显发现不了这种情形。
- ID 回显抓到了它本来要抓的问题：一个 50 单元的 mahabharata 批次返回的 ID 只有 0 到 21，缺了 28 个段落，而端点报告的是成功。

错误出现扫描：

- **第一个内容错误出现在每请求 64 个有效单元处**，只在 childrens-literature 的散文上，占单元的 9.4%。moby、spec 和 wasteland 直到 64 都无错。Codex 默认模型在 24 个单元时的 7.8% 来自单独一个错位请求，在 32、48 和 64 时都无错。
- **带来风险的是每个请求的内容量，而不是单元数。** 48 个单元共 4705 个原文 token 无错；64 个单元共 3563 个 token 出错；64 个短行单元（600 到 1100 token）无错。
- **token 预算在 1600 到 4800 范围内都没有错误。** 超过约 2000 后上升的是重试成本：在 2400、单元数较大时，请求开销 +150%。
- 错位呈 U 形：两单元的批次在 4 本书中的 3 本上错位（无害；阶梯会降到单个单元），中间段大多无事，到 64 时重试又会错位。
- 扫描发现了一条真实的损坏路径：一次在对账后仍未被纠正的单格错位，把三个字面的 `⟦spanN⟧` token 写进了可见的正文。这已修复：这样的批次现在会沿减半阶梯下降，而不是被写入。



*图：错误率和重试成本随单元数与预算的变化，76 个单元格，923 个请求。线条表示当时的默认值（32 个单元，1600 到 2000 的预算），即 9 月 7 日裁定之前。*

弱模型重跑：调高单元上限，先付出的是重试，之后才是错误，所以该调低的是单元上限，而不是预算。



DeepSeek 在每种上限和预算下都压缩得很厉害（中/英字符比中位数 0.25 到 0.31，gpt-4o-mini 为 0.31 到 0.35）。这个模型上的译文偏短是模型本身的问题，不是分组错误。



*图：弱模型重跑，来自 2026 年 9 月的分组报告；其逐单元格表格没有保留。*

## 决定

9 月 5 日，扫描的负责人把上限设为 32（测得的错误出现点的一半），并出于重试成本的考虑保留了 2000 token 的预算上限。9 月 7 日，所有者调低了所有分组默认值：

| 常量 | 旧值 | 新值 |
|---|---|---|
| `GENERAL_GROUP_MAX_UNITS`（plan.py） | 32 | **16**（测得错误起点 64 单元的四分之一） |
| `SUBSTRICT_GROUP_MAX_UNITS` | 16（派生值 `//2`） | **8**（仍是派生值） |
| `substrict_batch_cap`（chatgptapi） | 16 | **8** |
| `SESSION_BUDGET_FLOOR` / `CEILING` | 2400 / 3200 | **1200 / 1600** |
| `SUBSTRICT_BUDGET_FLOOR` | 派生值 1200 | **直接写定 800**（有意不用 `floor//2`=600——不叠加两重余量） |

所有者记录在案的理由：schema 支持是端点的属性，不能证明模型会遵循指令；强模型在格式遵从上未必更好；而出错意味着模型无法把请求撑住。错位一格就是一本错书，而不只是一本贵书。所以安全余量优先于测得的按内容计算的节省（预算 800 时每个内容 token 折合 15.5 个输入当量，1600 时为 12.3）。

为什么在较弱的端点上减半：9 月 5 日的 JSON 对象评测发现的两次内容退步都是大批次，而没有严格 schema 判定的端点，无法与那种会出现回复计数错误的端点区分开来。所以在这种端点上，单元上限是 8，每请求预算是 800。

什么会改变它：重跑扫描，发现某个模型在 16 以下就出错（调低），或者所有者认为余量不如节省值钱（调高）。在那之前，运行打印 `misaligned batches this run` 时就调低 `--max-batch-units`；只为成本调高它，风险自负，而且绝不要超过 48。

## 局限

- **扫描中的“弱”模型是 `gpt-5.4-mini`，它不能代表弱模型。** 它在格式遵从上异常强。因此，扫描得出的“弱模型比强模型更能守住回复格式”（childrens 的 9 个单元格中 2 次恢复，对 6 个中 5 次）这一结论，对真正的弱模型来说未经证实。`gpt-4o-mini` 和 DeepSeek 的重跑才是弱模型的证据，而其逐单元格表格没有保留。
- 只有四本书，而且只有 childrens-literature 在 128 个单元时读到了正文；moby 和 spec 的测试片段都花在了前置内容上。
- 在本地模型上，16 到 64 个单元之间没有任何测量。
- token 预算只在 1600 及以上测得无错，而且只是在旧的 32 单元上限下。随附的 800 到 1600 有意落在测量范围之下。

---

### Zh Evaluation Pdf Ocr Engines (`docs/zh/evaluation/pdf-ocr-engines.md`)

# 扫描页上的 OCR 引擎：`--ocr-engine` rapidocr、ocrmac、easyocr、tesseract

在这次测量之后，`pdf` 扩展开始安装 onnxruntime，并在 macOS 上安装 ocrmac，所以在全新安装上 rapidocr 和 ocrmac 都不再下载任何东西；下文关于安装的说明描述的是测量当时的安装情况。

## 摘要

`--ocr-engine` 选择 docling 在没有文字层的页面上运行哪个引擎。docling 能在本地运行的四个引擎，在同样的 20 页扫描页（14 页英文，6 页中文）上接受了测量：评分方法相同，要求识别的语言相同，全部在 CPU 上运行。在英文上，tesseract 的错误率最低（平均 CER 0.104），ocrmac 其次（0.136）；rapidocr（0.169）和 easyocr（0.267）落在后面。在简体中文上，rapidocr 最好（五页的平均汉字 CER 为 0.054，ocrmac 为 0.097，tesseract 为 0.116）；在仅有的一页繁体页面上，ocrmac 和 rapidocr 接近（0.043 和 0.060）。easyocr 几乎在所有地方都排最后，耗时是其他引擎的三倍，内存最多用到 6.6 GB，还在中文页面上丢了文字。Apple 自己的引擎 ocrmac 最快（每两页中位数 6 s），而且不需要下载任何东西。

默认值仍是 `auto`。本页是[选择 OCR 引擎](../features/pdf-ocr-engines.md)背后的依据。

## 实验设置

- **路线**：PDF 路线自己的提取，通过分步脚本运行（`tools/pdf_to_book.py extract`），docling 2.129.0，`--pdf-ocr --device cpu --pages 1-2`，默认 OCR 模式（在 docling 保留页面内嵌文字层的地方，文字层会被保留）。一次只做一个转换，每个都在内存上限之下运行，`HF_HUB_OFFLINE=1`。
- **引擎**：rapidocr 3.9.2，运行在 onnxruntime 1.23.2 上（PP-OCRv6 small 模型，随 rapidocr 包一起提供）；ocrmac 1.0.1（Apple Vision，macOS 26）；easyocr 1.7.2，运行在 PyTorch 2.9.0（CPU）上；tesseract 5.5.3（Homebrew），使用 `tessdata_fast` 语言文件（`eng`、`chi_sim`、`chi_tra`、`osd`）。
- **语言**：所有引擎都用同一个 `iso:` 标签，由 docling 映射成各引擎自己的代码：英文页面用 `iso:en`，简体中文用 `iso:zh-Hans`，繁体中文用 `iso:zh-Hant`。每个单元格中，运行自己打印的那一行都确认了引擎和语言。
- **设备**：每个单元格都用 `--device cpu`，所以四组的版面模型和表格模型都在 CPU 上运行。rapidocr（onnxruntime）、easyocr（PyTorch）和 tesseract 在 CPU 上运行；ocrmac 在 Apple 的 Vision 框架里运行，由框架自己选择硬件。
- **机器**：Apple 芯片，16 GB，Python 3.12.12。
- **页面**：每份都截成两页；计分的是有标准答案的那些页：

| 样本 | 计分页 | 内容 | 语言参数 |
|---|---|---|---|
| scan_cia_typewriter | 1, 2 | 质量较差的打字机稿扫描（CCITT） | `iso:en` |
| scan_cia_typewriter_mid | 2 | 打字机稿扫描，文档中部 | `iso:en` |
| scan_forward1963 | 1 | 1963 年的杂志，双栏，带脚注 | `iso:en` |
| scan_en_degraded_1_mid | 1 | 带表格的彩色小册子 | `iso:en` |
| scan_en_degraded_2_mid | 1 | 黑白二值的旧小说页面 | `iso:en` |
| scan_zh_hans_qm_mid | 1 | Internet Archive 的图书扫描，简体中文，带不可见 OCR 文字层和 JBIG2 蒙版 | `iso:zh-Hans` |
| zh_hant_scan_2_mid | 2 | 繁体中文，横排，带表格 | `iso:zh-Hant` |
| syn_arxiv_1706_03762_{clean,deg} | 1, 2 | 渲染成扫描件的原生数字论文 | `iso:en` |
| syn_wps_eula_{clean,deg} | 1, 2 | 渲染成扫描件的 WPS 许可协议 | `iso:en` |
| syn_web_zh_report_chrome_{clean,deg} | 1, 2 | 渲染成扫描件的中文网页报告 | `iso:zh-Hans` |

合成扫描件：200 dpi；干净版是无损 PNG，劣化版是灰度图，旋转 1.5°，模糊（r=0.8），JPEG 质量 q40。它们的标准答案是精确的（来自原页面的文字层）。真实扫描件的标准答案是对照页面图像人工录入的。中文图书扫描件带有 JBIG2 蒙版，所以每一组的页面图像都来自 pypdfium2（运行时打印了 JBIG2 那一行）。

- **评分**：归一化：去掉 Markdown 语法（图片链接、注释、`#`、表格竖线和分隔线、`**`、转义符、列表符号），反转义 HTML，NFKC，所有引号变体都映射为 `"`，所有连字符和破折号都删去，空白合并（中文则完全删去）。**CER** 对顺序敏感：一个段落读的顺序不同，也会计为错误。**Han CER** 是只计汉字的 CER。**line_err** 不计顺序：对标准答案的每一行取（1 − 部分匹配度），再按长度加权平均，所以它只衡量识别和检测本身。对于开头没有标题的提取结果，路线会在上方加一个标题（`# <file name>`），计分前把它去掉了。

命令，每个单元格一条（`$E` 是评测目录，`$ENGINE` 是四个引擎之一，`$LANG` 取自上表）：

```bash
HF_HUB_OFFLINE=1 TESSDATA_PREFIX=$E/tessdata \
  python tools/pdf_to_book.py extract $E/fixtures/p12/scan_cia_typewriter.pdf \
  --output $E/runs/K/$ENGINE_cia/bundle \
  --pdf-ocr --ocr-engine $ENGINE --ocr-lang iso:en --device cpu --pages 1-2
```

另外在每种文字各取一份样本，用 `--ocr-engine auto` 跑同样的命令，看看 `auto` 会选哪个。

## 结果

每页的 CER，越低越好；中文页面同时给出只计汉字的 CER：

| 页面 | rapidocr | ocrmac | easyocr | tesseract | auto |
|---|---|---|---|---|---|
| cia p1 | 0.158 | 0.119 | 0.073 | 0.120 | 0.119 |
| cia p2 | 0.272 | 0.109 | 0.148 | 0.110 | 0.109 |
| ciamid p2 | 0.188 | 0.029 | 0.080 | 0.017 | - |
| fwd p1 | 0.251 | 0.231 | 0.260 | 0.212 | - |
| deg1 p1 | 0.355 | 0.360 | 0.965 | 0.046 | - |
| deg2 p1 | 0.543 | 0.491 | 0.602 | 0.408 | - |
| zh p1 | 0.112 (Han 0.009) | 0.210 (Han 0.056) | 0.390 (Han 0.235) | 0.201 (Han 0.036) | 0.210 (Han 0.056) |
| hant2 p2 | 0.069 (Han 0.060) | 0.041 (Han 0.043) | 0.670 (Han 0.724) | 0.322 (Han 0.350) | 0.041 (Han 0.043) |
| synarxc p1 | 0.051 | 0.050 | 0.124 | 0.051 | - |
| synarxc p2 | 0.040 | 0.003 | 0.056 | 0.004 | - |
| synarxd p1 | 0.142 | 0.055 | 0.431 | 0.169 | - |
| synarxd p2 | 0.002 | 0.269 | 0.534 | 0.272 | - |
| syneulac p1 | 0.015 | 0.014 | 0.071 | 0.015 | - |
| syneulac p2 | 0.171 | 0.008 | 0.028 | 0.008 | - |
| syneulad p1 | 0.110 | 0.163 | 0.215 | 0.014 | - |
| syneulad p2 | 0.066 | 0.008 | 0.153 | 0.008 | - |
| synzhc p1 | 0.042 (Han 0.000) | 0.040 (Han 0.003) | 0.564 (Han 0.514) | 0.077 (Han 0.019) | - |
| synzhc p2 | 0.076 (Han 0.053) | 0.045 (Han 0.000) | 0.656 (Han 0.611) | 0.085 (Han 0.021) | - |
| synzhd p1 | 0.188 (Han 0.191) | 0.167 (Han 0.163) | 0.799 (Han 0.862) | 0.193 (Han 0.171) | - |
| synzhd p2 | 0.136 (Han 0.018) | 0.312 (Han 0.262) | 0.800 (Han 0.848) | 0.390 (Han 0.333) | - |

每页的 line_err（不计顺序）：

| 页面 | rapidocr | ocrmac | easyocr | tesseract | auto |
|---|---|---|---|---|---|
| cia p1 | 0.085 | 0.001 | 0.045 | 0.003 | 0.001 |
| cia p2 | 0.181 | 0.076 | 0.110 | 0.078 | 0.076 |
| ciamid p2 | 0.093 | 0.007 | 0.036 | 0.000 | - |
| fwd p1 | 0.009 | 0.020 | 0.054 | 0.007 | - |
| deg1 p1 | 0.010 | 0.043 | 0.559 | 0.033 | - |
| deg2 p1 | 0.054 | 0.118 | 0.291 | 0.019 | - |
| zh p1 | 0.036 | 0.144 | 0.142 | 0.119 | 0.144 |
| hant2 p2 | 0.050 | 0.037 | 0.536 | 0.272 | 0.037 |
| synarxc p1 | 0.034 | 0.033 | 0.057 | 0.033 | - |
| synarxc p2 | 0.025 | 0.002 | 0.034 | 0.004 | - |
| synarxd p1 | 0.035 | 0.034 | 0.265 | 0.082 | - |
| synarxd p2 | 0.001 | 0.003 | 0.316 | 0.007 | - |
| syneulac p1 | 0.001 | 0.000 | 0.047 | 0.001 | - |
| syneulac p2 | 0.109 | 0.002 | 0.018 | 0.002 | - |
| syneulad p1 | 0.001 | 0.041 | 0.117 | 0.000 | - |
| syneulad p2 | 0.047 | 0.002 | 0.085 | 0.002 | - |
| synzhc p1 | 0.031 | 0.030 | 0.482 | 0.062 | - |
| synzhc p2 | 0.059 | 0.035 | 0.563 | 0.067 | - |
| synzhd p1 | 0.089 | 0.043 | 0.693 | 0.074 | - |
| synzhd p2 | 0.047 | 0.044 | 0.719 | 0.117 | - |

按类别求平均，CER（中文为 Han CER）/ line_err：

| 类别 | n | rapidocr | ocrmac | easyocr | tesseract |
|---|---|---|---|---|---|
| 打字机稿扫描，英文（cia p1、p2，ciamid p2） | 3 | 0.206 / 0.120 | 0.086 / 0.028 | 0.100 / 0.064 | 0.082 / 0.027 |
| 杂志和老印刷品，英文（fwd、deg1、deg2） | 3 | 0.383 / 0.024 | 0.361 / 0.060 | 0.609 / 0.301 | 0.222 / 0.020 |
| 合成扫描，英文，干净 | 4 | 0.069 / 0.042 | 0.019 / 0.009 | 0.070 / 0.039 | 0.019 / 0.010 |
| 合成扫描，英文，劣化 | 4 | 0.080 / 0.021 | 0.124 / 0.020 | 0.333 / 0.196 | 0.116 / 0.023 |
| 图书扫描，简体中文（zh，JBIG2） | 1 | 0.009 / 0.036 | 0.056 / 0.144 | 0.235 / 0.142 | 0.036 / 0.119 |
| 扫描，繁体中文，横排（hant2） | 1 | 0.060 / 0.050 | 0.043 / 0.037 | 0.724 / 0.536 | 0.350 / 0.272 |
| 合成扫描，简体中文，干净 | 2 | 0.027 / 0.045 | 0.001 / 0.033 | 0.562 / 0.523 | 0.020 / 0.065 |
| 合成扫描，简体中文，劣化 | 2 | 0.105 / 0.068 | 0.213 / 0.044 | 0.855 / 0.706 | 0.252 / 0.096 |

按引擎：

| 引擎 | 页数 | 平均 CER，英文（n） | 平均 Han CER，中文（n） | 平均 line_err，英文 | 平均 line_err，中文 | 每个两页单元格的提取耗时中位数（s） | 内存峰值 MB（最大） |
|---|---|---|---|---|---|---|---|
| rapidocr | 20 | 0.169 (14) | 0.055 (6) | 0.049 | 0.052 | 12 | 2248 |
| ocrmac | 20 | 0.136 (14) | 0.088 (6) | 0.027 | 0.056 | 6 | 1661 |
| easyocr | 20 | 0.267 (14) | 0.632 (6) | 0.145 | 0.523 | 37 | 6596 |
| tesseract | 20 | 0.104 (14) | 0.155 (6) | 0.019 | 0.119 | 10 | 1902 |
| auto | 4 | 0.114 (2) | 0.050 (2) | 0.039 | 0.090 | 12 | 1977 |

提取耗时取自运行自己打印的 `PDF extracted: 2 pages, …, N s.` 这一行：包括加载模型，以及两页的版面、表格和 OCR。各单元格：rapidocr 9–19 s，ocrmac 4–11 s，tesseract 7–21 s，easyocr 32–90 s（90 s 的那个单元格包含首次使用时的模型下载）。

**`auto` 选了什么**：3 个单元格中 3 次都选了 ocrmac（`OCR engine: ocrmac (docling's choice on this install), …`），因为装了 ocrmac；它的文字与 ocrmac 单元格完全相同。

**有几处 CER 差距要用阅读顺序而不是识别来解释**。synarxd p2：ocrmac 和 tesseract 的 CER 为 0.269 和 0.272，rapidocr 为 0.002，但 line_err 为 0.003 和 0.007：文字是对的，只是有一块放到了别处。deg1：rapidocr 和 ocrmac 的 CER 为 0.355 和 0.360，line_err 为 0.010 和 0.043。对一本书来说，段落放错位置是实实在在的缺陷，所以 CER 是主要分数；line_err 说明文字本身是否读对了。

**easyocr 丢了文字**。在中文合成页面上，753 个汉字它只返回了 108 到 370 个；在 deg1 上，3,518 个字符它只返回了 133 个。它也是唯一内存超过 4 GB 的引擎：简体中文那个单元格超过了 6,000 MB，被上限杀掉，改用 8,000 MB 上限重试时峰值为 6,596 MB。

**安装与首次使用时的下载**，在这台机器上测得：

| 引擎 | `pdf` 扩展之外还需安装的 | 首次使用时下载的 | 之后能否离线运行 |
|---|---|---|---|
| rapidocr | 无：rapidocr 3.9.2（31 MB）在扩展里，它的 onnxruntime 模型就在包里；**onnxruntime（62 MB）不在扩展里** | 用 onnxruntime 时不下载（扫描过程中没有出现任何文件，无论 `iso:en`、`iso:zh-Hans` 还是 `iso:zh-Hant`）；用 PyTorch 时（没有 onnxruntime 时 `auto` 退回的方式）：从 modelscope.cn 下载 9.8 + 0.6 + 20.3 MB 的模型，观察到一次 | 能 |
| ocrmac | ocrmac 1.0.1 和 pyobjc（28 MB），仅限 macOS | 无：Apple 的 Vision 框架是 macOS 的一部分 | 能 |
| easyocr | easyocr 1.7.2（15 MB），外加 opencv-python-headless（98 MB）、scikit-image（28 MB）、networkx（7 MB）和一些更小的包：约 150 MB | 总会下载检测器 `craft_mlt_25k.pth`（83 MB），然后每种文字一个识别器：`english_g2.pth` 15 MB，`latin_g2.pth` 15 MB，`zh_sim_g2.pth` 22 MB，`chinese.pth`（繁体）226 MB，都是在这次测试中下载的。由 easyocr 自己从 GitHub 下载：`HF_HUB_OFFLINE` 拦不住它 | 能，从 `~/.EasyOCR/model` 读取 |
| tesseract | tesseract 程序（Homebrew：25 MB，外加 leptonica 7 MB 和它依赖的约 35 个库） | 无：语言文件要手动安装，`tessdata_fast` 中的大小为 `eng` 4.1 MB，`chi_sim` 2.5 MB，`chi_tra` 2.4 MB，`osd` 10.6 MB | 能 |

## 决定

- 默认值仍是 `auto`：docling 取 ocrmac、rapidocr 和 easyocr 中第一个已安装的，并报出它用的是哪一个。`--ocr-engine` 用来明确指定一个；没有安装的引擎，或在 macOS 以外指定的 ocrmac，会在读取任何页面之前被拒绝，并给出安装它的那条命令。
- [选择 OCR 引擎](../features/pdf-ocr-engines.md)上的建议依据的是按类别的那张表：英文用 tesseract 或 ocrmac（老印刷品上 tesseract 领先），简体中文用 rapidocr，繁体中文用 ocrmac 或 rapidocr，只有在别的引擎都认不出那种文字时才用 easyocr。
- `pdf` 扩展在 macOS 上安装 ocrmac，所以 `auto` 会选它，无需下载任何东西；扩展还安装 onnxruntime，所以 rapidocr 用它自带的模型运行，而不用去下载 PyTorch 的模型。

## 局限

- 共 20 页，每个类别一到四页；真实中文扫描的几行各只有一页。同一类别上两个引擎的 CER 相差不到约 0.02 时，换一页就可能改变，所以都算作打平。这些引擎的结果是确定的：rapidocr 精确复现了之前那项研究中 CIA 页面的数字（0.158、0.272），`auto` 也精确复现了 ocrmac 单元格的结果。
- 没有测量日文、韩文和其他文字。这里也没有测量竖排中文。
- 每个单元格只运行一次；没有用 GPU。在 GPU 上，easyocr 的耗时会下降，内存则不会。
- rapidocr 是在 onnxruntime 上测量的。在 PyTorch 上（没有 onnxruntime 的安装中 `auto` 退回的方式）没有计分：唯一的一次检查在下载之后就停止了。
- tesseract 需要把它的 `configs` 文件夹放在语言文件旁边：当 `TESSDATA_PREFIX` 指向一个只有 `.traineddata` 文件的文件夹时，所有 tesseract 单元格都失败了（`KeyError: 'text'`，tesseract 打印 `read_params_file: Can't open tsv`）。把 Homebrew 的 `configs` 和 `tessconfigs` 文件夹链接进去就解决了；上面的数字来自重跑。
- 默认 OCR 模式：在简体中文扫描件上，docling 会同时读取它的不可见 OCR 文字层，所以引擎读出的文字与这层文字混在一起。这里没有测量 `--ocr-replace-layer`。
- 真实扫描件的标准答案由一个人录入。

---

### Zh Evaluation Pdf Structure Llm Roles (`docs/zh/evaluation/pdf-structure-llm-roles.md`)

# 用图像模型判定区域角色：`--img-model`

## 摘要

docling 把每一页切分成区域（段落、标题、脚注、代码、表格、图片等），并排好阅读顺序。区域的角色或顺序一旦出错，书里就会显现出来：代码清单印成了脚注，第 2 节排在第 1 节前面，图题上方的插图不见了。这项研究在 85 页上整理了 docling 的错误目录，然后让一个视觉模型（gpt-5.6-luna）给它能看到的区域重新打标签。在原生数字页面上，模型修正了大部分错误标签。它修不了只有跨页才看得出的问题（页眉），也无法重建被 docling 打碎成碎片的页面。这些需要基于页面几何的规则，在任何模型之前运行。

角色校正已经实现：[PDF 路线](../features/pdf-to-epub.md#用视觉模型纠正区域角色)上的 `--img-model`。研究所要求的几何修复还没有实现，所以页眉和被打碎的页面仍按 docling 生成的样子输出。

## 实验设置

- docling 2.129.0，只用文字层（不做 OCR），在 43 个一到两页的切片上：共 85 页，其中 51 页 arXiv、16 页其他论文、4 页扫描件、14 页网页或 Word 页面。
- **错误目录：** 每一页都渲染出来，画出并标注区域，每个错误都由人工记录类别和严重程度（book-breaking 毁书级、visible 可见、minor 轻微）。
- **角色校正：** 12 页，每页至少有一个错误标签或页面装饰类错误。模型看到的是带标注的叠加图，以及区域 ID 列表，列出当前标签和前 80 个字符；它对每个 ID 回答一个标签，或 `abstain`。由脚本对照错误目录给答案打分；模型不自我评判。
- **整页探测：** 3 页，各缺一个区域。模型只看到干净的页面，返回带框的区域；以 IoU 0.5 及以上与 docling 的区域匹配。

## 结果

**错误目录：123 个错误，13 个毁书级。** 照抄自记录：

| fault_class | arXiv (26 sl/51 pp) | paper (8 sl/16 pp) | scan (2 sl/4 pp) | web (7 sl/14 pp) | total | book_breaking | visible | minor |
|---|---|---|---|---|---|---|---|---|
| wrong_label | 8 | 11 | 5 | 7 | 31 | 1 | 26 | 4 |
| paragraph_merge_split | 11 | 5 | 5 | 9 | 30 | 8 | 13 | 9 |
| column_order | 6 | 2 | 1 | 3 | 12 | 2 | 8 | 2 |
| heading_level | 0 | 6 | 0 | 5 | 11 | 0 | 1 | 10 |
| furniture_as_body | 2 | 1 | 3 | 4 | 10 | 0 | 8 | 2 |
| spurious_region | 7 | 2 | 0 | 0 | 9 | 0 | 3 | 6 |
| missing_region | 1 | 2 | 0 | 3 | 6 | 2 | 2 | 2 |
| caption_attachment | 2 | 2 | 0 | 2 | 6 | 0 | 4 | 2 |
| duplicate_asset | 1 | 0 | 0 | 3 | 4 | 0 | 0 | 4 |
| misplaced_asset | 1 | 0 | 0 | 1 | 2 | 0 | 2 | 0 |
| body_as_furniture | 0 | 1 | 0 | 1 | 2 | 0 | 1 | 1 |
| **all** | 39 | 32 | 14 | 38 | 123 | 13 | 68 | 42 |

错误标签是最常见的类别，但只毁掉过一次书。被打碎或错误合并的段落毁书 8 次：一个内嵌 OCR 层在一页上变成了 165 个区域，行内数学式被拆成 49 个单字形条目，一段代码清单丢掉了所有换行，还有 docling 的跨页合并把一个段落粘到了作者信息块上。

**缺失的文字不能用作触发条件。** 85 页中有 78 页，落在所有区域之外的文字层字符为 0.00%，任何一页最多也只有 1.23%。缺失的插图或公式没有文字层字符可缺。真正有效的信号是几何上的：孤立的图题、只装着编号的公式框、本该是表格的地方只有一条细长的图片。

**角色校正，12 页：**

| 页面 | 区域数 | 标签错误 | 已修正 | 保留了错误标签 | 改成了另一个错误标签 | 弃权 | 引入的错误 | 额外的正确修改 | 提示 / 补全（推理）token | 延迟 s |
|---|---|---|---|---|---|---|---|---|---|---|
| arxiv_1906_00495v1_p34 p4 | 17 | 3 | 1 | 2 | 0 | 0 | 0 | 0 | 3442 / 239 (157) | 5.6 |
| arxiv_2009_12164v1_p12 p1 | 13 | 5 | 5 | 0 | 0 | 0 | 0 | 1 | 3346 / 199 (130) | 5.7 |
| arxiv_2010_03667v1_p12 p1 | 20 | 4 | 4 | 0 | 0 | 0 | 0 | 1 | 3431 / 351 (254) | 7.6 |
| latexbook_p12 p2 | 11 | 6 | 2 | 4 | 0 | 0 | 1 (13:text->key_value_region) | 1 | 3190 / 520 (460) | 9.0 |
| mixed_photo_code_1_p3031 p1 | 17 | 9 | 9 | 0 | 0 | 0 | 0 | 0 | 2958 / 165 (84) | 6.0 |
| mixed_photo_code_1_p3031 p2 | 9 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 2739 / 150 (100) | 5.3 |
| statement_p12 p1 | 24 | 4 | 1 | 2 | 1 | 0 | 1 (18:text->key_value_region) | 1 | 3555 / 625 (512) | 6.0 |
| statement_p12 p2 | 9 | 5 | 0 | 5 | 0 | 0 | 0 | 0 | 3145 / 143 (94) | 3.5 |
| web_en_chrome_1_p12 p1 | 21 | 15 | 15 | 0 | 0 | 0 | 0 | 1 | 3308 / 619 (512) | 10.6 |
| word_en_table_1_mid p1 | 16 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 3451 / 265 (186) | 6.9 |
| word_en_table_1_p12 p2 | 5 | 2 | 2 | 0 | 0 | 0 | 0 | 0 | 3018 / 29 (0) | 2.7 |
| zhqm_mid p1 | 164 | 11 | 0 | 9 | 2 | 0 | 0 | 0 | 7860 / 1014 (345) | 9.5 |
| **合计（12 页）** | | 66 | 40 | 23 | 3 | 0 | 2 | 5 | 43443 / 4319 | 78.4 |



*上表 12 页中目录收录的标签错误，按模型的处理结果分类。共 66 个错误。*

- **66 个中修正了 40 个。** 除去银行对账单页面和 OCR 扫描件，是 46 个中的 39 个。
- **重复出现的页面装饰：8 个中修正 0 个。** 银行对账单反复出现的页头和监管声明块，在单独一页上看起来就像正文。只有它们跨页重复这一点才能把它们标识出来。
- **被打碎的 OCR 页面：11 个中修正 0 个。** 给碎片重新打标签并不能重建一页。
- **引入了 2 个错误**，都是 `text -> key_value_region`，危害很小。另有 5 处修改是正确的（文档标题）。模型从未弃权，连 164 个区域的那一页也没有。
- **成本：** 12 页共 43,443 个提示 token 和 4,319 个补全 token，每页 2.7 到 10.6 s。

**整页探测，3 页：** 模型找到了全部 3 个缺失的区域，标签也都正确（一张插图、一个带编号的公式、一张人口普查表）。它的框在 45 个区域中有 38 个与 docling 的匹配，平均 IoU 为 0.82 到 0.90，所以提出的框必须先吸附到页面自己的字形和图像边界上，才能从中裁剪任何东西。

**识别，推迟。** 表格：在 17 张表上，docling 的单元格 F1 为 0.888，模型为 0.854，其中 7 个标准答案按构造就等于 docling 的输出。docling 的公式解码在两页上跑了 27 分钟没有输出，被强行终止。

## 决定

这项研究得出的计划：

1. 几何修复最先运行，这不是模型的活：被打碎的行和段落、跨页合并、单字形碎条、代码换行，以及通过跨页重复找出的页面装饰。
2. 然后在清理过的区域 ID 上做角色校正。这是第一个模型步骤，因为它是代价最低的切入点，也是唯一测得有收益的一步。
3. 对缺失区域做整页提议，只在三个几何触发条件之一标记的页面上进行，框要吸附并检查。
4. 由模型判定阅读顺序尚未测试，仍是候选。表格和公式识别仍然推迟。

一次设计咨询（Codex，有记录）为第 2 步定下了约束：

- 在 OpenAI 风格的路线上加一个轻薄的图像适配层，复用现有的结构化输出阶梯；
- 角色修改是对条目的类型化替换，绝不是直接写标签；
- 页面装饰、表格、图片和列表不在第一轮范围内；
- 采纳的修改原子地应用；修改率异常的页面整页隔离，而不是削减（在首批真实运行后已改变，见下）；
- 每个判定都写入 `decisions.json` 以供审计；
- 默认关闭。

**实现了什么。** 第 2 步，即角色校正，先于几何修复实现。模型看到的是标注了 docling 区域的渲染页面，在严格的逐区域 schema 下，为每个符合条件的区域回答一个标签（text、section_header、title、caption、footnote、code 或 abstain）。符合条件的区域是正文、标题和小标题，且没有被任何图题或脚注引用、不在表格单元格和公式之内。列表、公式、表格、图片和页面装饰都不会被问到。采纳的回答会把条目替换成正确类型的条目，然后才运行公式和标题两个步骤。

首批真实运行，gpt-5.6-luna，四个测试样本的第 1-2 页，照抄自记录：

| 测试样本 | 询问 | 采纳（已应用） | 保留 | 拒绝 | 隔离页数 | 调用次数 | 提示 / 补全 token | 秒 |
|---|---|---|---|---|---|---|---|---|
| arxiv_2010_03667v1 | 27 | 6 | 21 | 0 | 0 | 2 | 7001 / 368 | 9.9 |
| web_en_chrome_1 | 28 | 4 | 24 | 0 | 0 | 2 | 6930 / 365 | 8.8 |
| mixed_photo_code_1_p3031 | 15 | 1（另有 9 个被隔离） | 5 | 0 | 1 | 2 | 5538 / 177 | 6.2 |
| arxiv_1706_03762（留出样本） | 21 | 2 | 19 | 0 | 0 | 2 | 6645 / 322 | 7.8 |

隔离机制挡掉了它遇到的唯一一个毁书级修正：在那一页代码上，模型 10 个回答中对了 9 个，这超过了 60% 的修改阈值。于是所有者去掉了隔离。现在，所询问条目中超过 60% 被修改的页面会保留修改，并打印 `Region roles: … on page N changed; read that page in source.md before translating.` 去掉隔离后重跑，那八行清单导出为代码（八个独立的代码块，因为相邻的代码条目还不会合并）。留出的那篇论文，Markdown 中改动的行数为 0。

**图像模型需要明确选择。** 这一步只在你指定的模型上运行，通过 `--img-model` 或提供方条目的 `img_model`。翻译模型永远不会被退而用于图像（所有者裁定），所以没有哪个默认设置需要能读图的模型。随附的 `openai` 提供方条目写的是 gpt-5.6-luna，所以 `--provider openai` 会开启这一步；`--img-model none` 关闭它。这一步之前，会用一张图片探测模型能否看到图片；不能的话，保留 docling 的标签。

**本地模型：** 每页约 3,000 个提示 token 不算多，但 164 个区域的那一页用了 7,860 个，这超出了 8B 级模型的能力。用 `--img-base-url` 把这一步指向托管的视觉模型，或者干脆关掉。

## 局限

- 角色校正只在 12 页上测量，整页探测只有 3 页。记录中“错误类别对应修正”的表格只是一个方向，不是比率。
- 错误目录由一名工作者根据叠加图整理；arXiv 上的标题层级没有评判（另有一项研究专门覆盖它们）。
- 只运行了一个模型（gpt-5.6-luna）。没有尝试任何本地模型。
- 已实现的这一步只在四个测试样本的第 1-2 页上检查过；像 text 改成 footnote 这类只改标签的修改，目前在导出中还不产生任何变化。
- 错误目录中没有运行任何 OCR 引擎；两份扫描件是通过它们内嵌的文字层读取的。

---

### Zh Evaluation Plan Classifier Jev (`docs/zh/evaluation/plan-classifier-jev.md`)

# 用 Jev 做计划分类器：`--classify-model`，对照 gpt-5.6-luna 测量

## 摘要

计划模式会就 EPUB 里的每一种块问模型：值不值得翻译。这是贴标签的活，不是写作的活，所以专门的分类器可以胜任。Jev 是 TypeSafe 的 System One 分类器，它回答类型化的选择题，并附带置信度。测试书的同样 31 个签名分别由 Jev 和 gpt-5.6-luna 分类。两者在 27 个上一致。4 处分歧全都是 luna 选了 `translate` 而 Jev 选了 `skip`，且每一处 Jev 的置信度都很低。Jev 用的提示 token 约为 luna 的两倍。第二轮让每页的共享文本只发送一次：Jev 的提示 token 减半，它与 luna 的一致程度的变化，没有超出同一份代码两次 Jev 运行之间的差异。同一轮还发现，Jev 概率上 0.5 的闸门在二选一的问题上永远不会触发；随后在一个 45 本 EPUB 的语料上测量了闸门，定为 0.95。结论：Jev 可以用作分类模型；默认仍是翻译模型。

## 实验设置

- **书：** `test_books/animal_farm.epub`，仓库里的测试书。它的计划有 31 个标签签名。
- **运行：** `--test --test_num 8 --quiet --language zh-hans`，用 gpt-5.6-luna 翻译，内存上限 1500 MB。
- **luna 组：** `--model gpt-5.6-luna --classify-model gpt-5.6-luna`。该端点校验严格的 JSON schema，所以签名按页发送，每个请求包含若干个。
- **Jev 组：** `--model gpt-5.6-luna --classify-model typesafe-ai/jev --classify-base-url https://ai-gateway.vercel.sh/typesafe`。每个签名一个 Choice 问题。在第一轮中，首选答案的置信度低于 0.5 时，Jev 回答 `unsure`。
- **固定引擎单元格：** `--api_format google --classify-model gpt-5.6-luna`，检查翻译服务路线能否从大模型分类器得到计划。
- **比较内容：** 两组在每个签名上的判定。没有人评判哪个判定是对的。

## 结果

照抄自记录：

| 单元格 | 分类 | 分类器用量行 |
|---|---|---|
| luna | 31 个签名，3 个请求，4 个跳过 | `Classifier (gpt-5.6-luna at the endpoint's default host): tokens: in 7.6k, out 1.8k, cached 0 (3 requests)` |
| Jev | 一次 503 重试，然后 31 个判定，8 个跳过 | `Classifier (typesafe-ai/jev at https://ai-gateway.vercel.sh/typesafe): tokens: in 14.4k, out 1.1k, cached 0 (3 requests)` |

**31 个中有 27 个一致。** 4 处分歧全都是 luna 选了 translate 而 Jev 选了 skip，而且 4 处都是 Jev 的低置信度判定（0.58–0.61）：

| 签名 | 样本文本 |
|---|---|
| `inline:span.underline` | www.ericseat.com |
| `inline:span.calibre_4` | www.ericseat.com |
| `inline:span.calibre_16` | "M" |
| `inline:span.calibre3` | George Orwell |

在两者一致的地方，Jev 的置信度大多在 0.9 以上。它用的提示 token 约为 luna 的两倍（14.4k 对 7.6k），因为每个签名都作为单独的问题发送。

**固定引擎加大模型分类器。** 谷歌翻译，以 gpt-5.6-luna 作分类器：`llm classification: 31 verdict(s), 4 skip(s)`、`Translation plan: … coverage 99.8%`，以及分类器的那一行 `Classifier (gpt-5.6-luna …): tokens: in 7.6k, out 1.6k (3 requests)`。译文紧挨着原文，并且与原文对应（George Orwell → 乔治·奥威尔）。

修正 key 路由之后的第二次 Jev 运行，重试了三次 503，最后是 `llm classification: 31 verdict(s), 8 skip(s)` 和 `Translation plan: 20 documents, 202560 chars, coverage 99.7%`。

### 第二轮：精简请求与闸门

原先每个 Jev 问题都会重复整段按签名生成的提示。精简映射把页面的共享文本作为 state 只发送一次，每个问题只带它自己的指令。没把握的 `skip` 现在回退为 `translate`，而不是 `unsure`。同一本书、同样的参数。照抄自记录：

| 实验组 | 分类器 token 输入 / 输出 | 请求数、每个的延迟 | 非 translate 判定 | 与 c_final（luna）一致 | 与 c_before（luna）一致 |
|---|---|---|---|---|---|
| (a) 之前，F 的映射，网关 | 14.4k / 1.1k | 3，经四次 503 重试之后（总耗时 36 s） | 7 | 27/31 | 26/31 |
| (a) 仅精简映射之后（`a_lean`） | 7.8k / 1.1k | 3（总耗时 14 s） | 9 | 25/31 | 未计算 |
| (a) 最终，精简 + 闸门 | 7.8k / 1.1k | 3：0.87、0.37、0.67 s | 9 | 25/31 | 26/31 |
| (b) luna schema（`c_final`） | 7.6k / ~1.4k | 3：5.07、6.03、4.15 s | 5 | — | 30/31 |

两次 luna 运行彼此一致 30/31，同一份代码的两次 Jev 运行（`a_lean`、`a_final`）一致 29/31。

**精简映射让 Jev 的提示 token 减半（14.4k → 7.8k），并缩短了总耗时（36 s → 14 s，含无重试这一因素）；一致程度的变化没有超出 Jev 自身运行之间的差异。**

**闸门的发现**，照抄自记录：

> 官方 Jev 的 `confidence` 字段不是所选选项的概率。在两个选项时，它约为 `2p − 1`（0.51 → 0.02，0.61 → 0.22，0.76 → 0.53）。代码保留了 F 的选择，优先读取 `probabilities[choice]`。在两个选项时它永远不会低于 0.5，所以 0.5 的闸门不起任何作用。
>
> 离线重放 `a_final`：在概率上设 0.75 的闸门——相当于在 Jev 自己的 `confidence` 上设 0.5——会把全部 5 个低置信度的跳过变成翻译，保留 4 个有把握的。与 luna 的一致会升到 30/31。这只是一本书、31 个签名。

那只是一本书、31 个签名，所以 0.75 只是一个提示，不是设定值。随后在语料上测量了闸门：在所选选项的概率上取 0.95，依据是 45 本 EPUB 语料的 662 个计划签名对照 gpt-5.6-luna 的结果，丢失一个跳过记成本 10，多翻译一个记成本 1。取这个值时，Jev 的跳过约有十分之九回退为 `translate`，留下来的都是附属内容：版权行、行号、注释标记、索引页码。从不跳过的成本是 140，加闸门后是 115，所以在这个语料上，Jev 比全部翻译省得不多。

## 决定

- Jev 可以用作分类模型：在 TypeSafe 自己的地址上用 `--classify-model jev`，用网关的 ID 加 `--classify-base-url` 和 `--classify-key`。见[提供方文件与额外模型](../providers.md#jev-与-jev-兼容分类器)。
- 默认分类器仍是翻译模型。
- 第一轮：弃权阈值保持 0.5；四个低置信度的跳过只做了记录，没有评判（两个 URL、一个单独的字母和一个光秃秃的作者名，阅读版保留或翻译都说得通）。
- 第二轮：请求精简了（共享文本只发一次），阈值变成了不对称的闸门。低于它的 `skip` 变成 `translate`；`translate` 不论概率多少都采纳。内容绝不会因为没把握的跳过而丢失。
- 闸门取所选选项概率的 0.95，来自上面的语料测量。`--classify-min-confidence P` 为单次运行调整它（`BBM_JEV_MIN_CONFIDENCE` 不用参数也能做到同样的事；两者都有时以参数为准）。

什么会改变它：在更多的书上做比较，按读者想要的结果评判判定，并给出两组的价格。

## 局限

- 一本书、31 个签名，每轮每组只跑一次。两轮的一致率、精简映射带来的 token 减半，以及 0.75 的重放，都只基于这一本书。
- 只有闸门的取值来自 45 本 EPUB 的语料，而那里的一致也仍然只是与 luna 的一致。
- key 是网关的 key，所以 TypeSafe 自己的地址没有实际跑过（它对这个 key 回了 401）。
- 只比较了 token 数，没有比较金额。
- 与 luna 一致不等于正确：luna 的判定也没有被评判。

---

### Zh Evaluation Session Compact Budget (`docs/zh/evaluation/session-compact-budget.md`)

# 会话压缩预算：`--context-compact-at`

## 摘要

在会话模式下，历史不断增长，直到达到 `--context-compact-at`，然后模型写一份交接报告，开启一个新窗口。预算短意味着接缝多；预算长意味着每个请求都要重读很长的历史。一项在三本书上做的 18 次付费运行研究，测量了成本随预算的变化。曲线在 1500 到 4000 之间是平的，之后上升，到 20000 时急剧上升。在 2500 的预算下，44 道接缝处的连续性都保持住了。9 月 7 日分组默认值减半之后，第二次测量发现 8192 的成本比 4096 **更高**：在一次 300 单元的运行中，相对旧默认值分别为 +39.8% 和 +27.3%。**默认值仍然保持 8192，是为了连续性，而不是价格。** 更低的值才是便宜的设置。

## 实验设置

**成本研究（2026 年 9 月 5 日）。** 18 次付费运行，OpenAI 端点上的 `gpt-5.6-luna`，共 3.267M token，`--language zh-hans`，`--use_context session`，每个单元格的规模约为 25 个请求。三本 epub-samples 书：childrens-literature（人名反复出现的文学散文）、moby-dick-mo（长章节）和 epub30-spec（短小、重复的技术单元）。

| 阶段 | 单元格 | 测量内容 |
|---|---|---|
| 0 | 3 本书，出厂默认值 | 校准：每请求开销 F、JSON 包装 j、交接提示 F_h、报告大小 K |
| 1 | C ∈ {1500, 4000, 8000, 20000} × 3 本书，B=800 | 成本随 C 变化的曲线 |
| 2 | moby-dick 上 B ∈ {400, 1600}（800 取自阶段 1） | 成本随 B 变化的曲线 |
| 3 | 一整本书，用解出的最优值（B=1088, C=2500） | 样本外验证 |

成本以输入 token 当量表示：`(tokens_in − tokens_cached) + 0.1·tokens_cached + 4.0·tokens_out`，即厂商的价格比例。当时的计量没有统计压缩轮次本身（在 C=1500 时最多占真实成本的 38%；此后已修复），所以每个总数都是计量成本加上重建的压缩轮次。

**新默认值的价格核查（2026 年 9 月 7 日）。** animal_farm，`gpt-5.6-luna`，会话模式，300 和 150 个单元，新的分组（16 个单元，预算 1200 到 1600）在 C=4096 和 C=8192 下，对照 9 月之前的默认值（预算 2400 到 3200，C=8000）。

## 结果

9 月 5 日，每本书按价格加权的总成本：

| 书 | C=1500 | C=4000 | C=8000 | C=20000 | 解出的 C* |
|---|---|---|---|---|---|
| childrens-literature | **218.8k** | 220.1k | 228.4k | 340.7k | 2512 |
| moby-dick-mo | 207.9k | **201.7k** | 237.2k | 263.3k | 2191 |
| epub30-spec | 117.2k | **108.4k** | 111.9k | 169.0k | 1580 |

- 三本书在 1500 到 4000 之间都平坦，差异在 0.6 到 3% 以内；三个解出的最优值都落在这一段。
- 结果记录认为 8000 比平坦区域高 4 到 18%。之后根据同样的单元格写成的分组报告认为，它比每本书自己的最优值高 9 到 25%。两者描述的是同样的运行，只是对照的基线不同。
- C=20000 的成本最多高出 56%，也是唯一出现漂移的单元格：在一个长窗口内，称谓从“你”变成了“您”。
- **短窗口下连续性依然保持。** C=2500 的整本书运行跨过 44 道接缝，1012 个节点中术语零断裂（皇帝 62/62，夜莺 38/38，公主 34/34，安徒生 12/12）。交接报告起到了不断累积的人名表的作用。
- 曲线背后的模型准确预测了整本书运行的请求数（82/82），成本预测误差在 −6.2% 以内。那次运行每个内容 token 的成本（11.7）低于网格中的每一个单元格（13.1 到 20.4）。
- 拟合常数：F=104，每单元 j=11.3，F_h=72，K=538（139 份报告），ρ 为 1.41 到 1.74，各书 g 为 1245/920/645，r 为 1.12 到 1.18，α 随 C 上升（childrens 上为 0.042 到 0.213）。



*图：9 月 5 日的成本模型与单元格。图中标出的是当天的默认值（C=8000，预算限定在 1600 到 2000），即 9 月 7 日裁定之前。12000 和 16000 处的空心方块是模型预测。*

9 月 7 日，新分组默认值的价格：

| 实验组 | 300 单元的请求数 | 全部 token | 压缩次数 |
|---|---|---|---|
| 旧默认值 | 33 | 283,361 | 8 |
| 新上限，C=4096 | 63 | 360,681 (+27.3%) | 19 |
| 新上限，C=8192 | 52 | 396,197 (+39.8%) | 9 |

150 个单元时：4096 为 +5.9%，8192 为 +24.0%。把 C 从 4096 提高到 8192，如预期那样让压缩次数回到了旧水平（19 降到 9，旧值为 8），账单却更高了。减半的预算让请求数成倍增加（33 变成 52 或 63），而每个请求都要重读携带的历史：每请求的提示负载分别为 7007（旧）、4690（4096 时）和 6537（8192 时）。缓存没能挽回：约 340k 的提示中只有 9k 到 21k token 被缓存。



*图：9 月 5 日的成本模型（左）和 9 月 7 日的价格核查（右）。脚本：`docs/img/src/session_compact_cost.py`。*

## 决定

`--context-compact-at` 在每次会话运行中默认都是 **8192**，不论是否分组，Codex 路线也一样。运行开始时会打印它。

理由是连续性，照顾的是在本地设备模型上运行本工具的众多用户，他们的成本曲线并不是这里测得的托管、带缓存的那一条。它**不是**便宜的设置。在当前的分组预算下，更低的值更便宜：300 个单元时，4096 用了 360,681 token，8192 用了 396,197。超过 16000 后成本急剧上升，而唯一观察到的漂移就出现在长窗口的单元格里。

什么会改变它：在变化后的分组预算下重新测量这两个单元格（4096 和 8192），或者在本地模型上做一次连续性测量。不重跑这两个单元格，就不要改动其中任何一个数字。

对你来说：不设置它，接缝最少。如果会话成本比接缝更要紧，就把它调低（往 4096 靠）。如果模型的输入上限更小，就设为那个上限。最小值是 1500。

## 局限

- 只有一个模型（`gpt-5.6-luna`），在一个带提示缓存的端点上。没有测量无缓存端点和本地端点。
- 每个单元格只是一次约 25 个请求的运行；单次运行上约 30% 以内的差异接近噪声。
- 9 月 7 日的价格核查只用了一本书（animal_farm）的两种规模。
- 连续性结论只基于一整本书（childrens-literature，zh-hans）。

---

### Zh Features Pdf Ocr Engines (`docs/zh/features/pdf-ocr-engines.md`)

# 选择 OCR 引擎

PDF 页面是扫描件时，`--pdf-ocr` 会让一个 OCR 引擎来读它。`--ocr-engine` 决定用哪一个。有四个引擎在你自己的机器上运行；它们擅长识别的内容不同，要安装的东西不同，是否需要下载也不同。

```bash
python make_book.py --book_name scan.pdf --to-epub --pdf-ocr \
  --ocr-engine ocrmac --ocr-lang iso:en --language zh-hans
```

先试两页（`--pages 1-2`），在为翻译付费之前读一读包里的 `source.md`。运行时会打印实际使用的引擎（`OCR engine: …`）。

## 需要 X，就用 Y

| 你的情况 | 使用 | 原因 |
|---|---|---|
| 用的是 Mac，并且不想下载任何东西 | `ocrmac`（装了 `pdf` 扩展就已经有了） | Apple 自己的引擎，是 macOS 的一部分：不用下载模型。测量中最快（每两页 6 s），英文上排第二，在打字机页面和干净页面上与最好的引擎打平。简体中文不如 rapidocr。 |
| 还没想好 | `auto`（默认） | 取 ocrmac、rapidocr、easyocr 中第一个已安装的，并报出它的名字。装了 `pdf` 扩展时，在 Mac 上是 ocrmac，其他系统上是 rapidocr。 |
| 简体中文 | `rapidocr` | 简体中文上最好：平均汉字错误率 0.054，ocrmac 为 0.097，tesseract 为 0.116。 |
| 繁体中文（横排） | `ocrmac` 或 `rapidocr` | 在测量的那一页上两者接近（0.043 和 0.060）。tesseract（0.350）和 easyocr（0.724）远远落后。竖排文字无论用哪个引擎，列的顺序都会出错：请检查 `source.md`。 |
| 日文或韩文 | `ocrmac` 或 `rapidocr`，配合 `--ocr-lang iso:ja` 或 `iso:ko` | 没有测量。两个引擎都支持这两种语言；tesseract 需要安装 `jpn` 或 `kor` 语言文件。 |
| 英文打字机稿或其他质量较差的扫描件 | `tesseract` 或 `ocrmac` | 在打字机页面上打平（错误率 0.082 和 0.086；rapidocr 为 0.206）。在老印刷品和一份杂志上，tesseract 明显领先（0.222；ocrmac 0.361，rapidocr 0.383）。 |
| 没有 GPU 的机器 | 除 `easyocr` 以外的任何一个 | 这里的每个数字都是在 CPU 上测得的。easyocr 每两页要 37 s（其他引擎 6 到 12 s），内存最多用到 6.6 GB。 |
| Linux 或 Windows | 英文用 `tesseract`，中文用 `rapidocr` | ocrmac 只在 macOS 上有。 |
| 上面这些都认不出的文字 | `easyocr` | 它有 80 多种语言的模型。在测量中，它在英文和中文上都排最后，还丢了文字。 |

## 各引擎的安装

| 引擎 | 安装 | 首次使用时下载 | 大小 |
|---|---|---|---|
| `rapidocr` | 随 `pdf` 扩展一起安装，连同 onnxruntime。 | 不下载：它的模型就在包里。 | rapidocr 31 MB，onnxruntime 62 MB |
| `ocrmac` | 在 macOS 上随 `pdf` 扩展一起安装（仅限 macOS）。 | 不下载。 | 28 MB |
| `easyocr` | `pip install easyocr` | 一个检测器（83 MB），外加每种文字一个模型：英文 15 MB，拉丁文 15 MB，简体中文 22 MB，繁体中文 226 MB，下载到 `~/.EasyOCR/model`。 | 约 150 MB 的包 |
| `tesseract` | tesseract 程序及其语言文件，放在 PATH 上：macOS 上 `brew install tesseract`，Debian 和 Ubuntu 上 `apt install tesseract-ocr tesseract-ocr-chi-sim`，Windows 上用 tesseract 项目提供的安装程序。 | 不下载：要加一种语言，就安装它的文件（fast 系列中 `eng` 4 MB，`chi_sim` 2.5 MB，`chi_tra` 2.4 MB）。 | 程序 25 MB，另加它依赖的库 |

四个引擎装好之后都可以离线工作；easyocr 还需要先把模型下载好。

你指定的引擎如果没有安装，或者在 Linux、Windows 上指定了 `ocrmac`，会在读取任何页面之前被拒绝，并给出安装它的那条命令。`auto` 永远不会被拒绝。

如果你把 tesseract 的语言文件放在自己的文件夹里（`TESSDATA_PREFIX`），也要把 tesseract 的 `configs` 文件夹放进去：没有它，tesseract 写不出 docling 要读的那张表，提取就会失败。

## 语言代码

`--ocr-lang` 接受每个引擎自己的代码，也接受一个在所有引擎上都有效的通用 `iso:` 标签。换引擎时，应该用 `iso:` 标签。

| 语言 | 任何引擎 | rapidocr | ocrmac | easyocr | tesseract |
|---|---|---|---|---|---|
| 英语 | `iso:en` | `en` | `en-US` | `en` | `eng` |
| 简体中文 | `iso:zh-Hans`（或 `iso:zh`） | `ch` | `zh-Hans` | `ch_sim` | `chi_sim` |
| 繁体中文 | `iso:zh-Hant` | `chinese_cht` | `zh-Hant` | `ch_tra` | `chi_tra` |
| 日语 | `iso:ja` | `japan` | `ja-JP` | `ja` | `jpn` |
| 韩语 | `iso:ko` | `korean` | `ko-KR` | `ko` | `kor` |

rapidocr 每次运行只识别一种语言（第一个）。不写 `--ocr-lang` 时，每个引擎都识别它自己的默认语言：rapidocr 是中文和英语，ocrmac、easyocr 和 tesseract 是英语、西班牙语、法语和德语。这时其他文字的扫描件就会读错。

## 测量了什么

20 页扫描页（14 页英文，6 页中文），每个引擎都被要求识别同一种语言，全部在 CPU 上运行。错误率是与人工录入的文本对照得出的字符错误率：0 表示完全正确，段落放错位置也会计为错误。

| 页面 | rapidocr | ocrmac | easyocr | tesseract |
|---|---|---|---|---|
| 英文打字机稿（3） | 0.206 | 0.086 | 0.100 | **0.082** |
| 英文杂志和老印刷品（3） | 0.383 | 0.361 | 0.609 | **0.222** |
| 英文，干净的合成扫描（4） | 0.069 | **0.019** | 0.070 | **0.019** |
| 英文，劣化的合成扫描（4） | **0.080** | 0.124 | 0.333 | 0.116 |
| 简体中文图书扫描（1），汉字 | **0.009** | 0.056 | 0.235 | 0.036 |
| 繁体中文扫描（1），汉字 | 0.060 | **0.043** | 0.724 | 0.350 |
| 简体中文合成扫描（4），汉字 | **0.066** | 0.107 | 0.709 | 0.136 |
| 每两页耗时（中位数） | 12 s | **6 s** | 37 s | 10 s |

各自在哪里胜出：

- **英文**：tesseract，ocrmac 紧随其后。在打字机页面和干净页面上两者打平（差距不到 0.005）。tesseract 在老印刷品上的领先是实打实的。在劣化的合成页面上，rapidocr 数字更低是因为阅读顺序：不计顺序的话，三者对文字的识别一样好。
- **简体中文**：rapidocr，平均值最好，5 页中有 3 页最好；ocrmac 在一张干净的合成页面上领先，ocrmac 和 tesseract 在一张劣化页面上领先。
- **繁体中文**：ocrmac 和 rapidocr，在仅有的一页上差距在噪声范围内。
- **easyocr** 除打字机稿之外在每一类中都排最后，速度最慢，内存占用也最大。

每个数字、所用的页面、评分脚本和命令：[扫描页上的 OCR 引擎](../evaluation/pdf-ocr-engines.md)。

---

### Zh Features Pdf To Epub (`docs/zh/features/pdf-to-epub.md`)

# PDF 转双语 EPUB

想把一份 PDF 做成带目录的双语书，先读两页，再跑整个文件：

```bash
python make_book.py --book_name paper.pdf --to-epub --pages 1-2 --test
python make_book.py --book_name paper.pdf --to-epub --use_context session
```

两条命令之间，读一读 `paper_pages-1-2_book/source.md`。[PDF 推荐设置](recommended-pdf.md)按文档类型和操作系统给出了对应的命令。

这条路线在 arXiv 论文和一批图书、扫描件、浏览器另存的网页上检验过，并没有覆盖所有形态的 PDF。欢迎提 issue 和 pull request；如果 PDF 可以公开，请附上它，否则附上 `source.md` 里出错的那一页。

## 它做什么

`--to-epub` 用 [docling](https://github.com/docling-project/docling) 的版面模型和表格模型把 PDF 读成 Markdown，用 Markdown 加载器翻译这份 Markdown，再由 Pandoc 生成可重排的双语 EPUB。每一段后面紧跟它的译文，目录按标题生成。标题层级从页面本身读出：先看编号（`1.`、`1.1`、`I.`、`A.`），再看字号和字重。插图保留为图片，每个行间公式都从页面上裁成图片，放回原来的位置。没有文字层的页面（即扫描件）只有在传入 `--pdf-ocr` 时才交给 OCR 模型读取；扫描件自带的文字层有误时，可以用 `--ocr-replace-layer` 替换掉。如果用 `--img-model` 指定一个视觉模型，它会在写出 Markdown 之前逐页查看，纠正 docling 给各个区域定的角色（比如其实是作者行的标题、被读成脚注的代码清单）。

所有文件都放在 PDF 旁边的工作目录 `<name>_book/` 里：`source.md`（提取结果）、`images/`、`book_bilingual.md`（译文）和一份清单。成书会复制到外面，名为 `<name>_bilingual.epub`。重跑同一条命令会复用已有的提取结果和已完成的翻译，所以你可以中途停下，读 `source.md`，改一个标题，再接着跑，不必付两次钱。PDF 是对页面的描述，而不是文档：它只记录字形放在什么位置，对段落和标题一无所知，所以任何提取器都只能把结构猜回来。标题差了一级、表格变成了正文，这是格式本身的局限，在 `source.md` 里花一分钟就能改好。

`source.md` 用一条注释 `` 标出每一页的开头。N 是该页在 PDF 文件中的序号，从 1 开始数，也就是 `--pages` 用的页码，而不是页面上印的页码。空白页同样保留标记，所以编号不会错位。极少数情况下，docling 会返回不属于任何页面的条目。这些条目只写一次，放在 `source.md` 末尾的 `` 下面，运行时会说明有多少条。它们在阅读顺序中的位置已经丢失：翻译之前，把它们移到该在的位置，或者删掉。

## 准备工作

1. 在仓库的克隆里安装 PDF 扩展：`pip install ".[pdf]"`（没有 NVIDIA 显卡的 Linux 和装有 NVIDIA 显卡的 Windows 还要加上 PyTorch 自己的索引，见[安装 PDF 扩展](../installation-pdf.md)）。此外，PATH 上需要有 Pandoc **3.1.12 或更新版本**。不需要 Java。
2. 模型（约 500 MB）在第一次运行时下载。
3. 在跑更长的内容之前先读前两页；在为整份翻译付费之前，至少读一遍 `source.md` 里的标题。它们会成为目录。

这条路线自己的参数：

| 参数 | 作用 |
|---|---|
| `--to-epub` | 走这条路线。 |
| `--pdf-ocr` | 读取没有文字层的页面。默认关闭：原生数字 PDF 本来就能直接读取，OCR 要多花好几倍的时间，读出的内容却不会变。无论开不开，版面、标题和表格检测都会运行。 |
| `--ocr-replace-layer` | 配合 `--pdf-ocr`：对每一页做 OCR，用识别结果代替 PDF 自带的文字层。默认关闭：保留已有的文字层，只对没有文字层的页面做 OCR。适用于文字层有误的扫描件（识别成了别的语言，或者是劣质 OCR 留下的乱码）；在干净的扫描件上，文字层比本地引擎读得更好。打开或关闭它，下一次运行都会重新读取 PDF。 |
| `--ocr-lang LANGS` | 配合 `--pdf-ocr`：OCR 引擎要识别的语言，用引擎自己的代码。`auto` 在 Mac 上选 ocrmac，其他系统上选 rapidocr；pdf 扩展把两者都装好了，都不需要下载任何东西。测量数据见[选择 OCR 引擎](pdf-ocr-engines.md)。各引擎的代码：rapidocr 用 `ch`、`en`、`latin`；easyocr 用 `ch_sim`、`ja`、`ko`；ocrmac 用 `zh-Hans`、`ja-JP`。在 BCP-47 标签前加 `iso:`（`iso:zh`、`iso:ja`、`iso:zh-Hant`）在所有引擎上都有效，所以不确定会用哪个引擎时，这是稳妥的写法。rapidocr 没有 `ch_sim`：中文请写 `iso:zh`。rapidocr 每次运行只识别一种语言，取第一个。运行时会打印实际使用的引擎和语言。 |
| `--ocr-engine ENGINE` | 配合 `--pdf-ocr`：由哪个 OCR 引擎读取页面：`auto`（默认；取 ocrmac、rapidocr、easyocr 中第一个已安装的）、`rapidocr`、`ocrmac`（macOS）、`easyocr` 或 `tesseract`。指定的引擎没有安装时，在读取任何页面之前就会被拒绝。该选哪个，有测量结果：[选择 OCR 引擎](pdf-ocr-engines.md)。 |
| `--device auto\|cpu\|cuda\|mps\|xpu` | 模型在哪里运行。`auto` 会检测 CUDA 或 MPS，找不到就退回 CPU。CPU 得到的文字完全一样，只是更慢。 |
| `--pages 12-30` | 只处理这些页，从 1 开始编号（也可以写 `1,3,5-7`）。这本书会有自己的名字：`<name>_pages-12-30_book/`、`<name>_pages-12-30_bilingual.epub`。 |
| `--no-formula-images` | 把行间公式留作 `` 占位符。几乎用不到：解析器从不读取公式，没有这些图片，数学内容就没了。 |
| `--pdf-image-dpi N` | 插图的清晰度，按 PDF 自身页面尺寸计算的每英寸点数（72 到 600）。默认 200：在平板或高像素密度的电子书阅读器上足够清晰；小一些的书用 150，带细小标注的插图用 300。换一个值重跑，只会重画插图，提取结果和译文都保留。公式有自己的分辨率，不受影响。如果一张插图只是一幅嵌入的图片，而且这幅图片自己的分辨率更低，就保留它自己的分辨率；任何插图都不会超过 560 万像素。 |
| `--img-model MODEL` | 根据页面图像纠正区域角色的视觉模型。只有在这里指定，或在提供方条目的 `img_model` 中指定时才开启；绝不会退而使用翻译模型。`none` 关闭条目里指定的模型。见[用视觉模型纠正区域角色](#用视觉模型纠正区域角色)。 |
| `--img-base-url URL` | 这个模型所在的地址，当它不在本次运行的端点上时使用（仅限 OpenAI 兼容接口）。 |
| `--img-key KEY` | `--img-base-url` 所用的 key。默认在本次运行自己的端点上使用本次运行的 key。 |

Markdown 加载器的所有参数都照常可用：`--use_context session`（推荐）、`--glossary`、`--parallel-workers`（不能与会话同用）、`--no-thinking`、`--test`。完整列表见 [PDF 参数与纯文本路线](../formats/pdf.md)。翻译阶段会拒绝的参数组合，例如在 codex 路线上用 `--no-thinking`，在提取开始之前就会被拒绝，所以不会先花钱。`--classify-model` 在这条路线上暂时不起作用，运行时会给出警告。

## 用视觉模型纠正区域角色

docling 把每一页切成若干区域并给它们打上标签：正文、标题、文档标题、图题、脚注、代码、表格、图片。有些标签是错的，成书里就会露出来：作者行出现在目录里，代码清单被排成正文，插图的标注被升格成一节。使用 `--img-model` 时，每一页都会连同 docling 画好并编号的区域一起交给模型。模型为每个区域给出一个角色（正文、标题、文档标题、图题、脚注、代码），或者弃权。被采纳的回答会在写出 Markdown 之前替换原来的标签，这样目录和代码块就能正确生成。文字本身绝不会被改写。

- **它修不了什么。** 列表、表格、图片、公式以及页眉页脚都不在询问范围内。docling 已经把一张扫描页切成碎片时，重新打标签也拼不回来。
- **它的开销。** 每页约 3,000 个提示 token；研究中每页耗时 2.7 到 10.6 s。在它背后的研究里，模型在 12 页上纠正了 66 个错误标签中的 40 个；见[用图像模型判定区域角色](../evaluation/pdf-structure-llm-roles.md)。
- **它需要什么。** 一个能接受图片的 OpenAI 兼容端点。运行时会先检查一次模型能否看懂图片。如果不能，运行时会说明这一点，并按 docling 自己的标签提取。
- **模型要明确指定，绝不自动推定。** 只有用 `--img-model` 或在提供方条目的 `img_model` 中指定了模型，这一步才会运行。自带的 `openai` 条目指定了 `gpt-6-luna`，所以 `--provider openai` 会开启这一步；`--img-model none` 可以关掉它。本地运行的翻译模型绝不会被要求去读页面。
- **改动它会重新提取。** 图像模型及其地址属于提取结果的比对依据，所以添加、更换或去掉 `--img-model` 都会重新读取 PDF。如果某个包的角色判定没有完成，而本次运行又要求做这一步，也会重新读取。

判定结果保存在 `<name>_book/.work/extraction/decisions.json`。提取结束时，运行会单独打印一行这一步的 token 用量：`Image model (<model> at <address>): …`。

## 手工修补译文

如果某一段译文有误，在 `<name>_book/book_bilingual.md` 里改正，然后做两步：

1. 在仓库目录下运行 `python tools/pdf_to_book.py export path/to/<name>_book`。它会根据你的修改重新生成 `<name>_book/book_bilingual.epub`；不会重新提取或翻译，也不需要 key。
2. 用 `<name>_book/book_bilingual.epub` 覆盖 PDF 旁边的 `<name>_bilingual.epub`。导出只在工作目录里写文件，所以在你复制之前，PDF 旁边那本书里还是旧的文字。

## 推荐命令

每份 PDF 都从两页和几段译文开始（`--pages 1-2 --test`），然后读一遍 `source.md`。小说、教材、论文、扫描书、文字层有误的扫描件和中文扫描件各自的命令，以及 macOS、带 NVIDIA 显卡的 Linux、Windows、只有 CPU 的机器和 Docker 上的安装方式与设备选择，都在 [PDF 推荐设置](recommended-pdf.md)里。

## 已知限制

- 行内数学公式可能无法正确显示。只有行间公式会被裁剪成图片；句子里的数学式按提取器读到的文字原样输出。

## 可能出现的问题

这条路线上的每一种失败都会打印一行以 `Error:` 开头的信息，并且尽可能在花钱之前就打印出来。下面列出这些信息，以及该怎么做。

### 提取之前

- **`Pandoc is required for --to-epub. Install it and make sure pandoc is on PATH.`** 从 [pandoc.org](https://pandoc.org/installing.html) 安装 Pandoc 3.1.12 或更新版本。
- **`pandoc 3.1.3 is too old for EPUB export; Pandoc 3.1.12 or newer is required …`** 你的 Pandoc 来自 apt（Ubuntu 24.04 自带 3.1.3，Debian 13 自带 3.1.11）。安装官方发布版，并把它放在 PATH 的最前面；这行信息还提到了另一种办法：分步脚本 `tools/pdf_to_book.py --pandoc PATH`。
- **`reading a PDF needs the pdf extra, which is not installed.`** 在克隆的仓库里运行 `pip install ".[pdf]"`（[安装 PDF 扩展](../installation-pdf.md)的第 3 步）。不是 `pip install "bbook_maker[pdf]"`。
- **`--device cuda was asked for, but the installed PyTorch is a CPU-only build.`** 按 CUDA 的方式重新安装。**`… but this machine has no cuda accelerator available.`** 改用 `--device cpu` 或 `--device auto`。
- **`--parallel-workers is not supported with --use_context session …`** 二者选其一。
- **`--no-thinking has no request to travel in on the codex route: …`** 在 codex 上去掉 `--no-thinking`。
- **`--img-model needs an OpenAI-compatible endpoint; … resolves to the … format.`** 图像模型要在本次运行的端点或 `--img-base-url` 上询问，而那个端点不是 OpenAI 形式的。给一个 OpenAI 兼容的 `--img-base-url`（以及 `--img-key`），或者去掉 `--img-model`。
- **`--img-base-url names where --img-model is served, and no --img-model was given. …`** 把模型也写上。
- **`--ocr-replace-layer re-reads pages that already carry a text layer with the OCR engine, so it needs --pdf-ocr.`** 加上 `--pdf-ocr`。

### 提取期间

- **`N of M selected pages have no text layer (page(s) …); rerun with --pdf-ocr to read them with the OCR models.`** 这份 PDF（或其中一部分）是扫描件。加上 `--pdf-ocr`。
- **`No --ocr-lang given: the OCR engine reads its own default languages, which may not be the pages'; …`** 随后是 **`OCR engine: rapidocr (docling's choice on this install), languages: the engine's defaults.`** 检查 `source.md`。如果扫描件不是中文或英文，用 `--ocr-lang` 重跑；包会被重新读取。
- **`The parser produced no text for a document whose pages have no text layer; the OCR pass returned pictures only.`** 或 **`Warning: no text was recognised on page(s) …`** 引擎认不出这种文字。用 `--ocr-lang` 指定页面的语言后重跑。
- **`The parser returned no text for this PDF; there is nothing to translate. If its pages are scans, rerun with --pdf-ocr.`** 照它说的做。
- **`Warning: page N extracted C characters, several times what a printed page holds; inspect source.md before translating.`** 那一页上有什么东西（通常是一张插图）携带的文字远比它显示出来的多。一页的 Markdown 长达几千行，那是垃圾，不是长页面。把它从 `source.md` 里删掉，或者用 `--pages` 把这一页排除在外。
- **`The PDF carries JBIG2 image masks, which docling-parse renders wrongly (docling issue #4329); page images are rendered by pypdfium2 instead, the text layer still by docling-parse.`** 提示信息。Internet Archive 和 ABBYY FineReader 生成的扫描件常用这种蒙版，docling-parse 会把页面画成一片模糊。此时整份文档的页面图像都改由 pypdfium2 生成。
- **`N of M selected pages carry only an invisible OCR text layer (a scanned book with recognised text underneath); that layer is kept as the page's text …`** 提示信息。使用 `--pdf-ocr` 时，这层文字可能与 OCR 引擎读出的内容混在一起，所以请用 `--ocr-lang` 指明语言；语言不对的话，一页清晰可读的内容也会变成乱码。如果这层文字本身就是乱码，用 `--pdf-ocr --ocr-replace-layer` 加上语言重跑。
- **`OCR: replacing the embedded text layer on every page (--ocr-replace-layer)`** 提示信息。没有 `--ocr-lang` 时，后面还会跟着语言提示。
- **`page N: the OCR engine read nothing where the PDF carried a text layer; …`** 在 `--ocr-replace-layer` 下，这一页的识别结果为空，它就保持为空页：不会拿原来的文字层来顶替。这行信息也会写进清单的限制说明；超过十页后，其余的只计数（`... and N more`）。用 `--ocr-lang` 指定页面的语言后重跑，或者去掉 `--ocr-replace-layer` 以保留文字层。
- **`The OCR engine read nothing on any selected page, and with --ocr-replace-layer the PDF's text layer is not used, so there is nothing to translate; …`** 运行在任何翻译付费之前停止。清单会记录这次失败的尝试：原因、空白页和所用的设置。常见原因是 `--ocr-lang` 写错了，或者扫描件的语言不在引擎的默认语言之内却没有写 `--ocr-lang`。写对语言后重跑，或者去掉 `--ocr-replace-layer` 以保留文字层。
- **`N item(s) carry no page number; they are placed after the last page in source.md.`** 很少见。docling 返回了不属于任何页面的条目。它们在 `source.md` 末尾的 `` 下面，在阅读顺序中的位置已经丢失。翻译之前，把它们移到该在的位置，或者删掉。
- **`… has no model for the OCR language 'ch_sim'. …`** 引擎不认识这个代码；信息里列出了它认识的代码。在 rapidocr 上，中文请写 `iso:zh`。这次什么都没有读取。
- **`… did not read the probe image (…); image steps are skipped this run.`** 这个图像模型在那个端点上看不到图片。提取会继续，使用 docling 自己的标签。
- **`Region roles: A of B asked items changed by <model> (… kept, … rejected, … pages with many changes); overlay at <path>.`** 提示信息：图像模型改了哪些。**`Region roles: C of D asked items on page N changed; read that page in source.md before translating.`** 一页中的大部分都被改了；这些改动会保留，所以请看一看那一页。**`Region roles: …; the detector's own labels stand there.`** 这一步有一部分没有完成（预算用完、回答被拒绝、某个区域没有得到回答）；这些区域保留 docling 的标签。
- **`Extracting again: the bundle's region-role pass is <status>; this run asks for --img-model …, which only a complete pass satisfies.`** 提示信息。之前的判定没有完成，所以重新读取 PDF。
- **`--img-model <model> failed: …`** 图像端点返回了一个等待也解决不了的错误，比如 key 被拒绝。运行停止。修正 key（`--img-key`）或地址。

### 提取之后、翻译之前

- **`The document does not open with a top-level heading, so a heading "<name>" was added above its text; …`** EPUB 的目录要求开头有一个一级标题。愿意的话，在翻译之前到 `source.md` 里给它改个名。
- **`Page 12: the selection starts inside a section, so a heading "Page 12" was added above its prose; …`** 同上，针对从某一节中间开始的 `--pages` 范围。
- **`The page selection is not one run of pages, so pages 1-7 were read and the ones outside the selection dropped afterwards; …`** 有间隔的范围会读取它跨越的所有页。一个连续的范围读取的页更少。
- **`Display formulas kept as images: N. …`** 提示信息：这些公式是图片，不会被翻译。
- **`Warning: a formula region on page N covers S% of the page, which is a layout mistake rather than an equation; …`** 这个区域保留为占位符，而不是整页的图片。其他 `Warning: … formula …` 行表示某个公式无法放置，它的占位符会保留下来。
- **`Extraction: removed N control character(s) that docling read from glyphs on page(s) …; they are not text and would break the EPUB.`** 提示信息。docling 把某些符号（通常是减号或约等号）读成了不可见的控制字符，它们已经从 `source.md` 中删去。这些符号在那几页上就缺失了；如果要紧，在 `source.md` 里补回去。
- **`Unsupported Markdown structure: control character U+0000 at line N. …`** `source.md` 里仍有控制字符（包是在上面那行信息出现之前提取的，或者是手工编辑带进来的）。删掉它再重跑；此时还什么都没有翻译。
- **`Unsupported Markdown structure: raw tex '\s' at block 29 … Normalize the source before translation.`** 提取器不会转义正文中的 Markdown，所以一句含有 `\s`、`[u](y)` 或 `<k>` 的话会被当成原始 TeX、链接或 HTML。在 `source.md` 里把它转义后重跑；提取不会重做。
- **`Reusing the extraction made with docling … on …; this run would use docling … on …. Delete the bundle directory to extract again.`** 提示信息。这份提取结果来自另一个设备或另一个 docling 版本。

### 翻译与导出

- **`Translation reused: … (same source and settings; delete it to translate again).`** 提示信息。删除 `book_bilingual.md` 即可重新翻译。
- **`Error: translate failed: Source or translation settings changed; start a new translation bundle.`** 你在翻译到一半之后改了模型、语言或 `source.md`。用原来的设置重跑，或者把这个包移开，从头开始。
- **`Error: … Bilingual Markdown was edited; rebuild the EPUB from it with: python tools/pdf_to_book.py export <name>_book, then copy <name>_book/book_bilingual.epub over <name>_bilingual.epub.`** 你手工编辑了 `book_bilingual.md`。这是允许的，但运行不会覆盖它。按这行信息说的做两步（信息里带有完整路径）：导出会根据你的修改在工作目录里重新生成成书，复制则把它放到 PDF 旁边；见[手工修补译文](#手工修补译文)。如果想重新翻译，删除 `book_bilingual.md`。
- **`EPUB navigation is invalid: …`** 这些标题构不成可用的目录。在 `source.md` 里修正标题层级（一个 `#` 文档标题，然后是 `##`、`###`）后重跑。
- **`Interrupted. Rerun the same command to resume.`** 按了 Ctrl+C。重跑即可；已完成的阶段不会重复。

### 运行结束后

- **`Image model (<model> at <address>): tokens: …`** 图像模型自己的用量，在提取之后打印，与翻译的用量分开。
- **`Nothing on this route classifies yet, so --classify-model is ignored on a … book.`** 某个分类参数或提供方的 `classify_model` 传到了 `source.md` 的翻译阶段，而那里没有任何东西做分类。无害。

### 需要了解的限制

- 插图保留为图片，其中的标注不翻译。
- EPUB 中没有 `bbm_translation_metadata.json`，这条路线暂时也不支持 `--no_disclosure`：署名行总会加上。
- `--glossary-auto` 只在发生压缩时学习，所以一篇短论文在默认预算下什么也学不到。
- 在带有良好可见文字层的扫描件上，`--ocr-replace-layer` 读出的效果不如文字层（字符更少，还有明显的误读），所以只在文字层有误时使用它。在带有不可见文字层的扫描件上，两者给出的文字几乎相同。
- 带有不可见文字层时，单用 `--pdf-ocr` 就已经会让 OCR 引擎读取页面，这就是上面中文扫描件的数字相近的原因。目前还做不到在开启 OCR 的同时原封不动地保留这样的文字层。
- 清单只在全部为空而停止的情况下记录失败的尝试。

---

### Zh Features Plan Mode (`docs/zh/features/plan-mode.md`)

# 计划模式

## 它做什么

EPUB 把文字放在多种标记里：段落、标题、列表项、表格单元格、引文、诗行、图题。计划模式会把它们全部找出来。加载器把整本书切分成单元，按标签签名（标签及其 class）给单元分组，然后问模型哪些签名值得翻译。答案写进 `<book>_plan.json`，同一本书之后的每次运行都会复用它。没有计划时，只翻译 `--translate-tags` 选中的标签（默认 `<p>`），`<p>` 之外的诗歌或表格会一声不响地保留源语言。

计划模式还会给工作分组。相邻的单元在 token 预算（`--accumulated_num`）和单元数上限（`--max-batch-units`，默认 16）之内合成一次请求。每个请求都要求按 id 返回各个单元，所以回复里漏掉、合并或打乱了单元时，能被发现并拆成更小的部分重试，而不会让后面每一条译文都错开一个位置。对任何大模型路线上的 EPUB，计划模式默认开启，不需要加参数。这些默认值背后的数据见[每次请求的单元数与 token 数](../evaluation/grouping-batch-size.md)。

## 准备

不需要安装任何东西。计划模式需要一本 EPUB 和一个用来分类的模型。在任何大模型路线上，这个模型就是翻译用的模型。[翻译服务](../machine-args.md)路线上没有模型可问，所以运行会回退到 `--translate-tags` 选中的标签，除非你用 `--classify-model` 指定一个分类器（见[选择分类器](#选择分类器)）。

参数：

| 参数 | 作用 |
|---|---|
| `--plan-classify auto` | 默认值。由翻译用的模型决定：端点经验证确实执行 JSON schema 时通过 schema，否则通过普通对话，用单个词 `skip`/`translate`/`unsure` 作答。回答 unsure 或无法解析时都翻译。 |
| `--plan-classify model` | 与 `auto` 相同，但有无法确定的行时中止运行，而不是回退。 |
| `--plan-classify all` | 翻译所有单元；不分类，不写计划文件。 |
| `--plan-classify none` | 不用计划：翻译 `--translate-tags` 选中的标签，与计划模式出现之前一样。 |
| `--plan-classify agent` | 写出带样本的计划，为编程智能体（或你本人）打印说明，然后停止。重新运行同一条命令开始翻译。 |
| `--classify-model MODEL` | 用另一个模型，或一个 Jev 兼容的分类器（`jev`）来分类。在命令行上写明时，运行进入 `model` 模式，分类失败会中止运行。`--plan-classify-model` 是旧名字，仍然可用。 |
| `--classify-base-url URL` | 该模型的服务地址，当它不是本次运行的端点时使用：一个 OpenAI 兼容地址，或一个 Jev 兼容的分类器的 URL。 |
| `--classify-key KEY` | `--classify-base-url` 使用的 key。 |
| `--plan-dry-run` | 打印按签名统计的覆盖率表，写出所有决定都为空的计划，然后退出。不需要 key。 |
| `--plan-min-coverage FRACTION` | 计划覆盖的文字比例低于这个值时中止（默认 0.5；`0` 关闭这项检查）。 |
| `--accumulated_num N` | 每个请求的 token 预算。不设时由运行自动推算：用自带提示词时为 1200，自定义 `--prompt` 较长时最多 1600，在不支持严格 schema 的端点上为 800（会话模式运行保持未减半的值）。`1` 关闭合并。 |
| `--max-batch-units N` | 每个请求最多包含的单元数。默认 16；在不支持严格 schema 的端点上为 8。 |
| `--exclude-translate-tags TAGS` | 其内容从不发送的标签（默认 `sup,code`）。 |
| `--only_filelist`, `--exclude_filelist` | 只为这些内部文件做计划，或跳过这些文件。预览运行同样遵守它们。 |

在计划模式下不要传这些参数：`--translate-tags`（会被忽略，计划已经覆盖一切）、`--allow_navigable_strings`（会被忽略）、`--block_size` 和 `--batch_size`（它们会重新切分计划已经切好的文字）。`--retranslate`、`--batch` 和 `--sentence_mode` 与计划相矛盾，会被拒绝。

## 选择分类器

分类器按以下顺序确定：`--classify-model`，然后是提供方条目的 `classify_model`，最后是翻译用的模型。

- **怎么问。**分类器的端点经验证支持严格 JSON schema 时，一个请求携带一页签名，回复受 schema 约束。其他情况下是普通对话：每轮五个签名，每个回答 `skip`、`translate` 或 `unsure`。连续两次回复不合格式，就降到每轮三个，再降到每轮一个。连一个都不行时，分类停止，剩下的签名全部翻译。每降一级都会打印一行。
- **单独的分类器**（通过参数或提供方条目指定）会为整本书做计划，无论翻译路线本身能做什么。运行会打印 `plan mode: on (classified by …)`。翻译服务路线就是这样用上计划的。用 gpt-5.6-luna 当分类器、Google 翻译负责翻译，在测试书上完整跑过：31 个签名全部有了决定，覆盖率 99.8%。
- **agent 模式不问任何模型。**`--plan-classify agent` 把所有未决定的行交给你或你的编程智能体。指定的分类器不会预先填写计划，因为智能体面对预填的答案判断得更差；运行会警告它被忽略了。`--plan-classify all` 也会忽略它。
- **在哪里问、用哪个 key**，见[提供方文件与额外模型](../providers.md#各模型在哪里被询问)。在单独地址上的分类器，会在运行结束时打印自己的用量行。
- **Jev** 是 TypeSafe 的分类器，专为这个问题而生：翻译还是跳过，每个请求一页签名，一次廉价的往返就够。`--classify-model jev` 就会使用它；TypeSafe 前面的网关（Vercel 的，或 Cloudflare AI Gateway）也可以。哪个 key 发往哪里，以及 URL 规则，见[提供方文件与额外模型](../providers.md#jev-与-jev-兼容分类器)。
- **Jev 的闸门。**拿不准的 `skip` 会改为 `translate`，所以不会因它丢失内容。闸门设在 Jev 所选答案的概率 0.95 处，这个值是以 gpt-5.6-luna 为参照，在 45 本 EPUB 的 662 个计划签名上测出来的。在这个值下，Jev 的跳过约有十分之九会回退为 `translate`；它仍然跳过的是校勘材料（版权行、行号、注释标记、索引页码）。在这个语料上，Jev 比全部翻译省不了多少。计划文件会在每个回退的行上注明（`… below the gate: translate`）。`--classify-min-confidence P` 可以为一次运行调整闸门（`BBM_JEV_MIN_CONFIDENCE` 不用参数也能做到）；调低会保留更多 Jev 的跳过，风险自负。见[用 Jev 做计划分类器](../evaluation/plan-classifier-jev.md)。

=== "翻译服务路线加大模型分类器"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --api_format google \
      --classify-model gpt-6-luna \
      --language zh-hans
    ```

    分类器请求发往 OpenAI，读取 `OPENAI_API_KEY`。翻译仍由 Google 完成。

=== "随附提供方文件里的 Jev"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --provider openai-jev \
      --use_context session
    ```

    一行搞定：用 OpenAI 的 gpt-6-luna 翻译（`OPENAI_API_KEY`），用 Jev 为计划分类（`JEV_API_KEY`），两者都来自随附的 `bbm_providers.example.json` 里的 `openai-jev` 条目。

=== "TypeSafe 上的 Jev"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --model gpt-6-luna \
      --classify-model jev \
      --use_context session
    ```

    读取 `JEV_API_KEY` 或 `TYPESAFE_API_KEY`。

=== "经网关使用 Jev"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --model gpt-6-luna \
      --classify-model typesafe-ai/jev \
      --classify-base-url https://ai-gateway.vercel.sh/typesafe \
      --classify-key "$GATEWAY_KEY" \
      --use_context session
    ```

    必须写明 key：Jev 的环境变量只会自动发往 typesafe.ai 的地址。

=== "经 Cloudflare AI Gateway 使用 Jev"

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --model gpt-6-luna \
      --classify-model jev \
      --classify-base-url https://gateway.ai.cloudflare.com/v1/$ACCOUNT_ID/$GATEWAY_ID/custom-typesafe \
      --classify-key "$JEV_API_KEY" \
      --use_context session
    ```

    TypeSafe 是你的网关上的一个[自定义提供方](https://developers.cloudflare.com/ai-gateway/configuration/custom-providers/)，slug 为 `typesafe`，base URL 为 `https://api.typesafe.ai`。网关开启了认证时，设置 `CF_AIG_TOKEN`；如果网关保存了 TypeSafe 的 key，就去掉 `--classify-key`。

## 推荐命令

按书的类型（小说、教材、论文、词典）、按端点（托管、本地、翻译服务）和按系统给出的命令，见 [EPUB 推荐设置](recommended-epub.md)。

## 可能出现的问题

下面是运行会打印的提示、它们的含义，以及该怎么做。

- **`plan mode: on (endpoint verified strict JSON schema)`**、`plan mode: on (no structured output here; classifying over a plain session)` 或 `plan mode: on (classified by …)`。不是问题，只是说明计划将如何决定。
- **`plan: this endpoint missed the reply format twice at 5 per turn; continuing at 3 per turn`**。普通对话分类器降了一级。什么都不用做，只是多花几轮。**`plan: this endpoint missed the reply format twice even one at a time; classification stops here and the remaining N signature(s) are translated`**。模型守不住单个词的回答格式。书照样会翻译，而且宁多勿少。想要更好的计划，就用 `--classify-model` 指定一个更强的分类器，或者使用 `--plan-classify agent`。
- **`… doesn't apply JSON schema (…), using delimiter method`** 或 **`… honors JSON schema shape but not value constraints; using the delimiter method for translation, schema kept for classification`**。不是问题。端点不做严格的 schema 解码，所以翻译改用分隔符格式。在 anthropic 路线、大多数代理和本地服务器上，这是预期行为。
- **`plan mode: off (…)`**。运行翻译 `--translate-tags` 选中的标签。括号里写明了原因：路线没有模型、给了 `--translate-tags`，或者探测失败。如果这不在你意料之中，就看看原因。在不会自己做计划的路线上，运行会建议使用 `--plan-classify model`。
- **`N misaligned batches this run — if this keeps happening, a lower --max-batch-units or --accumulated_num may fit this model better`**。从第三个恢复的批次起打印。模型在大请求上总是数错。没有丢失任何内容（每个出错的批次都拆成两半重试过了），但会多花请求。下次运行把单元数上限减半：`--max-batch-units 8`，然后 `4`。
- **`Plan coverage X% is below the required 50.0% — refusing to translate a fraction of the book silently.`** 计划会跳过书的大部分内容。打开 `<book>_plan.json`，看看哪些被标成了 `skip`。词典或带大量校勘材料的书可能确实只需翻译较少的部分；这时就有意识地调低 `--plan-min-coverage`。
- **`--classify-model names a classifier, and --plan-classify agent leaves every row to your agent and asks no model; it is ignored this run.`**（或 `… all translates the whole partition …`）。如其所言；去掉这个参数或换个模式。
- **`--plan-classify model asks an LLM to rule on every plan signature, and the google format translates through one fixed engine with no model to ask. …`** 固定引擎的运行处于 `model` 模式，却没有自己的分类器。加上 `--classify-model`，或者改用 `agent` 或 `all`。
- **`--classify-model needs an OpenAI-compatible endpoint; … resolves to the … format.`** `--classify-base-url` 不是 OpenAI 形态。**`--classify-base-url names where --classify-model is served, and no --classify-model was given. …`** 把模型也写上。
- **`No API key for the jev classifier at … Pass --classify-key, …`** Jev 的环境变量只在 typesafe.ai 地址上读取。经网关使用时，用 `--classify-key` 写明 key。
- **`BBM_JEV_MIN_CONFIDENCE must be a number from 0 to 1; got …`** 改正或取消这个变量。
- **`<book>_plan.json has N undecided signature(s) …`**。用过 `--plan-classify agent` 之后，还有一些行没有决定。把每个 `action` 都填成 `translate` 或 `skip`，然后重新运行。
- **`…: invalid action '…' — use …`**。手工编辑计划时打错了字。改正 JSON 后重新运行。
- **续跑因指纹信息被拒绝。**写下检查点之后，书或计划发生了变化。用原来的参数重新运行，或者删掉检查点从头开始——前提是清楚哪些部分要重新付费。
- **计划跳过了你想要的内容。**删掉 `<book>_plan.json` 再运行一次，或者手工编辑其中的 `action` 字段。`--test` 运行会为整本书分类，而不只是那一小段，所以它得出的计划就是真正的计划。

---

### Zh Features Recommended Epub (`docs/zh/features/recommended-epub.md`)

# EPUB 推荐设置

对大多数书来说，整条命令就是这样：

```bash
bbook_maker \
  --book_name my_book.epub \
  --language zh-hans \
  --use_context session \
  --quiet
```

书会按[计划](plan-mode.md)翻译（找出每一块文字，由模型决定哪几类值得翻译），[会话](session-mode.md)让人名和术语保持一致。本页其余部分讲的是：当你的书、你的模型或你的机器不属于常见情况时，该改什么。

## 需要什么，就传什么

| 如果你需要…… | 传入 | 延伸阅读 |
|---|---|---|
| 几乎不花钱先看一眼 | `--test --test_num 8` | [快速开始](../quickstart.md) |
| 付费之前先看哪些会翻译、哪些会跳过（不需要 key） | `--plan-dry-run` | [计划模式](plan-mode.md) |
| 每一块都翻译，不做分类 | `--plan-classify all` | [计划模式](plan-mode.md) |
| 自己或借助编程智能体决定计划 | `--plan-classify agent`，然后重新运行同一条命令 | [计划模式](plan-mode.md) |
| 全书必须统一的人名和术语 | `--glossary names.txt` | [会话模式](session-mode.md) |
| 你自己的文风或语域 | `--prompt my_prompt.json` | [提示词文件](../prompt.md) |
| 只翻译部分章节 | `--only_filelist ch03.xhtml,ch04.xhtml`（EPUB 内部的文件名） | [EPUB](../formats/epub.md) |
| 长书翻得更快，一致性其次 | `--parallel-workers 4` 配合不带值的 `--use_context`（会话无法在多个 worker 之间共享） | [EPUB](../formats/epub.md) |
| 在视觉上把译文区分出来 | `--translation_style "color:#808080;font-style:italic"` | [命令行参数](../cmd.md) |
| 只要译文，不要原文 | `--single_translate` | [EPUB](../formats/epub.md) |
| 不要 AI 翻译署名行 | `--no_disclosure`（这样读者就无法再分辨译文是否出自人手） | [EPUB](../formats/epub.md) |
| 按了 Ctrl+C 或崩溃后继续 | 同一条命令加上 `--resume` | [快速开始](../quickstart.md#运行中断时) |

## 按书的类型

=== "小说"

    就用本页开头的命令。如果某个反复出现的人名在窗口接缝之后译法漂移了，用术语表文件把它固定下来：

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --language zh-hans \
      --use_context session \
      --glossary names.txt \
      --quiet
    ```

=== "带表格和公式的教材"

    先预览计划。教材会把文字放在表格单元格、图题和侧栏里，你会希望在付费之前看到哪些会被跳过。

    ```bash
    bbook_maker \
      --book_name textbook.epub \
      --plan-dry-run
    ```

    如果表格里有你想要却没被选中的文字类型，就改为翻译所有单元而不做分类，并把需要在几百页里保持统一的术语固定下来：

    ```bash
    bbook_maker \
      --book_name textbook.epub \
      --language zh-hans \
      --plan-classify all \
      --use_context session \
      --glossary terms.txt \
      --quiet
    ```

    EPUB 里的公式是 MathML 或图片。MathML（`<math>`）和 SVG 从不发给模型，所以公式保持原样。

=== "论文"

    EPUB 格式的论文用与小说相同的命令。PDF 格式的论文见 [PDF 推荐设置](recommended-pdf.md)。

=== "词典或校勘本"

    一本以校勘材料为主的书（词头、版本符号、行号），需要翻译的文字本来就不到一半，运行会在覆盖率检查处中止。先用 `--plan-dry-run` 预览，再有意识地调低这项检查：

    ```bash
    bbook_maker \
      --book_name edition.epub \
      --language zh-hans \
      --plan-min-coverage 0.2 \
      --use_context session
    ```

=== "扫描件，或中文扫描件"

    扫描件是 PDF，而计划模式只用于 EPUB，见 [PDF 推荐设置](recommended-pdf.md)。把中文 EPUB 译成英文，按小说处理即可：

    ```bash
    bbook_maker \
      --book_name chinese_novel.epub \
      --language en \
      --use_context session
    ```

## 按模型和端点

| 如果你的端点是…… | 传入 | 原因 |
|---|---|---|
| OpenAI、Anthropic，或支持提示缓存的网关 | `--use_context session` | 历史按缓存价重读 |
| 不支持提示缓存的端点（进度条上的 `cached=` 一直是 0） | 不带值的 `--use_context` | 会话会在每个请求里为整段历史付全价 |
| Gemini 或 Qwen | `--api_format gemini --use_context`（或 `qwen`） | 它们自己维护历史，并拒绝会话模式 |
| 你的 ChatGPT 套餐 | `--api_format codex`，不加上下文参数 | 线程本身就是上下文；见[用大模型翻译](../llm-args.md) |
| 翻译服务（Google、DeepL……） | `--classify-model gpt-6-luna` | 引擎无法分类，没有大模型分类器时只翻译 `<p>`；见[翻译服务](../machine-args.md) |
| 小型本地模型（8B 到 16B） | 用默认值；推理模型加 `--no-thinking`，并把 `--context-compact-at` 设为模型的输入上限 | 在不支持严格 schema 的端点上，运行会自动把请求大小减半；见[本地模型](../llm-args.md#本地模型ollamallamacpplm-studio) |
| 总是错位的模型（`N misaligned batches this run`） | `--max-batch-units 8`，然后 `4` | 请求更小；见[每次请求的单元数与 token 数](../evaluation/grouping-batch-size.md) |
| 计划分类总是失败的小模型 | `--plan-classify all` | 跳过分类，翻译每一块 |

## 按系统

活是在端点上干的，所以用托管端点时，每个系统上的命令都一样。只有模型在本地运行时，系统才有影响。

=== "macOS（Apple 芯片）"

    通过 Ollama 运行本地模型，它使用 Metal。把会话窗口限制在小模型的上下文长度之内：

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --api_base http://localhost:11434/v1 \
      --model qwen3:8b \
      --use_context session \
      --context-compact-at 4000
    ```

    小型本地模型不支持严格的 JSON schema，所以运行会自动把单元数上限减半到 8。

=== "带 NVIDIA 显卡的 Linux"

    在 GPU 上运行一个提供 OpenAI 兼容 API 的本地服务器（vLLM、llama.cpp、Ollama）：

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --api_base http://localhost:8000/v1 \
      --model your-model-id \
      --use_context session \
      --context-compact-at 4000
    ```

=== "仅 CPU"

    在 CPU 上用本地模型翻完一整本书很慢，而每个请求都重读一遍历史就更慢了。请使用支持提示缓存的托管端点：

    ```bash
    bbook_maker \
      --book_name novel.epub \
      --model gpt-6-luna \
      --use_context session
    ```

=== "Docker"

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:latest \
      --book_name /book/novel.epub \
      --use_context session \
      --quiet
    ```

    见 [Docker](../docker.md)。

---

### Zh Features Recommended Pdf (`docs/zh/features/recommended-pdf.md`)

# PDF 推荐设置

每份 PDF 都用同样的方式开始：两页、几段译文，然后读一读产出的内容。

```bash
python make_book.py \
  --book_name book.pdf \
  --to-epub \
  --pages 1-2 \
  --test
```

它只提取这两页，几乎不花钱。打开 `book_pages-1-2_book/source.md`，至少读一遍标题：它们会成为目录。一页的 Markdown 长达几千行，那是某张插图带出来的垃圾，不是长页面。看起来没问题，就运行下面与你的文档对应的命令。如果有不对的地方，在 `source.md` 里改好再重跑；提取结果会被复用。

这条路线需要 [PDF 扩展](../installation-pdf.md)和 Pandoc 3.1.12 或更新版本。每个参数的作用见 [PDF 转双语 EPUB](pdf-to-epub.md)。

## 需要什么，就传什么

| 如果你的 PDF 是… | 传入 | 原因 | 详见 |
|---|---|---|---|
| 任何想做成书的 PDF | `--to-epub --use_context session` | 生成带目录的可重排 EPUB；会话让每个短小的文本块都能看到它前面的内容 | [PDF 转双语 EPUB](pdf-to-epub.md) |
| 扫描件（没有文字层） | `--pdf-ocr` | 不传它，没有文字的页面会被拒绝，所以你永远不用猜 | [OCR 参数](pdf-to-epub.md#准备工作) |
| 中文、日文、韩文或其他非拉丁字母文字的扫描件 | `--pdf-ocr --ocr-lang iso:ja`（或 `iso:ko`，…） | 默认的 OCR 引擎只识别它自己的语言：在 Mac 上（ocrmac）是英语、西班牙语、法语和德语，其他系统上（rapidocr）是中文和英语 | [OCR 语言](pdf-to-epub.md#准备工作) |
| 中文扫描件 | `--pdf-ocr --ocr-lang iso:zh`（繁体用 `iso:zh-Hant`） | 不是 `ch_sim`：rapidocr 会拒绝它 | [中文扫描件](#按文档类型) |
| 简体中文扫描件 | `--pdf-ocr --ocr-engine rapidocr --ocr-lang iso:zh` | 测量中汉字错误率最低 | [选择 OCR 引擎](pdf-ocr-engines.md) |
| 在 Mac 上，并且不想下载任何东西 | `--pdf-ocr`（auto 会选 ocrmac，pdf 扩展在 Mac 上会安装它；`--ocr-engine ocrmac` 可以直接指定它） | Apple 的 Vision 框架，无需下载；在英文和繁体中文上与最好的引擎只差一点，简体中文扫描件上 rapidocr 更好 | [选择 OCR 引擎](pdf-ocr-engines.md) |
| 已经带有 OCR 文字层的扫描件（Internet Archive、ABBYY） | 不用额外传什么 | 运行时会使用这层文字，并说明这一点 | [扫描书](#按文档类型) |
| 文字层是乱码的扫描件 | `--pdf-ocr --ocr-replace-layer --ocr-lang <lang>` | 每一页都由 OCR 引擎重新读取 | [文字层有误的扫描件](#按文档类型) |
| 论文，或任何带代码清单的文档 | `--img-model gpt-6-luna` | 视觉模型会纠正被当成标题的作者行和被读成脚注的代码清单，每页约 3,000 个提示 token | [纠正区域角色](pdf-to-epub.md#用视觉模型纠正区域角色) |
| 想一次翻译一章的长书 | `--pages 12-30` | 每个范围都有自己的书，互不覆盖 | [PDF 参数](../formats/pdf.md) |
| 满是数学公式 | 不用额外传什么 | 行间公式默认保留为图片 | [PDF 参数](../formats/pdf.md) |
| 人名或术语的译法必须固定 | `--glossary terms.txt` | 钉住的译法，只随出现它们的文本块一起发送 | [会话模式](session-mode.md) |
| 在一台 GPU 不太正常的机器上 | `--device cpu` | 文字完全一样，只是更慢 | [按系统](#按系统) |

## 按文档类型

=== "小说"

    排印的小说标题少，也没有表格。不想要的书前内容，用 `--pages` 排除。

    ```bash
    python make_book.py \
      --book_name novel.pdf \
      --to-epub \
      --language zh-hans \
      --use_context session \
      --quiet
    ```

    先检查 `source.md` 里的章节标题：小说的章节常常不带编号，这时它们的层级只能靠字号来判断。区域角色判定是在论文、网页和代码清单上测量的，没有在小说上测量过；使用 `--provider openai` 时它是开启的，用 `--img-model none` 可以省下这部分 token。

=== "带表格和公式的教材"

    不用 OCR 也能检测表格。行间公式会变成图片；公式周围的文字会翻译，公式本身不翻译。

    ```bash
    python make_book.py \
      --book_name textbook.pdf \
      --to-epub \
      --pages 12-30 \
      --language zh-hans \
      --use_context session \
      --glossary terms.txt
    ```

    用 `--pages` 一次翻译一章；每个范围都有自己的书。带代码清单的教材能从 `--img-model gpt-6-luna` 中受益：清单的各行会以代码的形式输出，而不是变成脚注。句子里的行内数学式不是公式区域，不在处理范围内：文字层或 OCR 把它读成什么样，它就是什么样。

=== "论文"

    论文会被提取成许多短小的文本块；会话让每个文本块都能看到它前面的内容。

    ```bash
    python make_book.py \
      --book_name paper.pdf \
      --to-epub \
      --language zh-hans \
      --use_context session \
      --img-model gpt-6-luna
    ```

    在大多数论文上，标题层级都完全正确（20 篇 arXiv 论文中，195 个标题有 187 个正确）。`--img-model` 会把被当成标题的作者行或插图标注降级，每页约 3,000 个提示 token；不传它就不在这上面花钱。如果不想为参考文献付费，用 `--pages` 把它排除在外。

=== "扫描书"

    没有文字层的页面，在你传入 `--pdf-ocr` 之前都会被拒绝。

    ```bash
    python make_book.py \
      --book_name scan.pdf \
      --to-epub \
      --pdf-ocr \
      --language zh-hans \
      --use_context session
    ```

    不写 `--ocr-lang` 时，OCR 引擎只识别它自己的默认语言：在 Mac 上（ocrmac）是英语、西班牙语、法语和德语，其他系统上（rapidocr）是中文和英语。其他语言的扫描件需要 `--ocr-lang`。

    **如果扫描件已经带有 OCR 文字层**（Internet Archive 和 ABBYY FineReader 生成的文件常常如此），先不加 `--pdf-ocr` 试一次：运行会打印 `… selected pages carry only an invisible OCR text layer …` 并使用这层文字，它通常比本地重新做一遍 OCR 读得更好。如果这层文字其实是乱码，见下一个标签页。

    `--img-model` 对扫描件作用不大：版面检测器已经切成碎片的页面，它拼不回来。

=== "文字层有误的扫描件"

    如果页面图像很清晰，`source.md` 读起来却不知所云，说明扫描件的文字层有误：识别成了别的语言，或者是劣质 OCR 留下的乱码。`--ocr-replace-layer` 会丢弃这层文字，改用 OCR 引擎读取每一页。

    ```bash
    python make_book.py \
      --book_name scan.pdf \
      --to-epub \
      --pdf-ocr \
      --ocr-replace-layer \
      --ocr-lang iso:zh \
      --language en \
      --use_context session
    ```

    在 `--ocr-lang` 里写扫描件本身的语言（这里是 `iso:zh`，对应中文扫描件）：语言不对，引擎就什么也读不出来。先试两页。引擎一无所获的页面会被点名并留空；如果一页都没读出来，运行会在任何翻译付费之前停止。文字层读起来没问题的扫描件不要用它：重新做的 OCR 不如一层好的文字层。

=== "中文扫描件"

    指明 OCR 引擎要识别的语言。`iso:` 标签无论运行哪个引擎都有效：简体用 `iso:zh`，繁体用 `iso:zh-Hant`。不是 `ch_sim`：rapidocr 会在读取任何页面之前拒绝它。

    ```bash
    python make_book.py \
      --book_name scan.pdf \
      --to-epub \
      --pdf-ocr \
      --ocr-lang iso:zh \
      --language en \
      --use_context session
    ```

    横排文字读得很好。**竖排文字**（从上到下、从右到左排印的繁体书）输出时列的顺序是错的。翻译竖排扫描件之前，先检查 `source.md`。

## 按系统

这条路线不需要 GPU。设备只影响速度，从不影响文字。从仓库的克隆安装；[安装 PDF 扩展](../installation-pdf.md)逐行解释了每一条命令。

=== "macOS（Apple 芯片）"

    ```bash
    pip install ".[pdf]"
    ```

    直接在本机安装；`--device auto` 会找到 MPS，运行时打印 `PDF extraction device: mps.`。Mac 上的 Docker 用不到 MPS。

    ```bash
    python make_book.py \
      --book_name paper.pdf \
      --to-epub \
      --use_context session
    ```

=== "带 NVIDIA 显卡的 Linux"

    ```bash
    pip install ".[pdf]"
    ```

    `--device auto` 会找到 CUDA，运行时打印 `PDF extraction device: cuda.`。想确保用上，就明确指定它；如果用不了，运行会拒绝并说明原因：

    ```bash
    python make_book.py \
      --book_name paper.pdf \
      --to-epub \
      --device cuda \
      --use_context session
    ```

=== "带 NVIDIA 显卡的 Windows"

    ```bat
    pip install ".[pdf]" ^
        --extra-index-url https://download.pytorch.org/whl/cu126
    ```

    PyPI 上的 Windows wheel 只支持 CPU，所以要指定 PyTorch 的 CUDA 索引，并安装 NVIDIA 驱动。之后的运行方式与 Linux 相同。

=== "只有 CPU"

    在 Linux 上，加上 PyTorch 的 CPU 索引（约 380 MB，而不是约 3.2 GB 的 CUDA）：

    ```bash
    pip install ".[pdf]" \
        --extra-index-url https://download.pytorch.org/whl/cpu
    ```

    在 Windows 上，普通的 `pip install ".[pdf]"` 装的就是 CPU 版。文字与 GPU 上完全一样，只是提取更慢（一份两页的 OCR 扫描件：CPU 上 26.3 s，Apple 芯片的 GPU 上 10.6 s）。

    ```bash
    python make_book.py \
      --book_name paper.pdf \
      --to-epub \
      --device cpu \
      --use_context session
    ```

=== "Docker"

    `pdf` 镜像标签带有 Pandoc 和 PDF 相关的包。把模型放在一个卷里，这样只需下载一次。

    ```bash
    docker run --rm \
      -v "$PWD":/book \
      -v bbm-models:/root/.cache \
      -e OPENAI_API_KEY \
      ghcr.io/yihong0618/bilingual_book_maker:pdf \
      --book_name /book/paper.pdf \
      --to-epub \
      --use_context session
    ```

    在带 NVIDIA 显卡的 Linux 或 Windows（WSL2）上，改用 `pdf-cuda` 标签（只有 amd64）并加 `--gpus all`。见 [Docker 安装](../docker.md)。

## 对任何 PDF 都该有的预期

- 插图保留为图片，其中的标注不翻译。
- 行间公式是从页面上裁下来的图片：在原来的位置可以看到，但不翻译，也搜索不到。行内数学式不在处理范围内。
- 含有 `\s`、`[u](y)` 或 `<k>` 的句子可能在翻译之前被拒绝；在 `source.md` 里把它转义后重跑。
- 在这条路线上，EPUB 总是带有那一行 AI 翻译署名；这里不支持 `--no_disclosure`。
- PDF 是对页面的描述，而不是文档：标题差了一级、表格变成了正文，这是格式本身的局限，在 `source.md` 里花一分钟就能改好。

---

### Zh Features Session Mode (`docs/zh/features/session-mode.md`)

# 会话模式

## 它做什么

`--use_context session` 让整本书保持一段对话。每个请求都往对话里追加内容，也都携带整段对话，所以模型在翻译下一段之前，读过的是前面大约一章的内容。人名、语域和术语就是这样从一页到另一页保持一致的。在支持提示缓存的端点上，已经发送过的历史按缓存价计费，所以很长的历史实际花费远低于它的体量。另一种上下文模式是不带值的 `--use_context`（窗口模式），每个请求只重发最近几对原文与译文。

历史不能无限增长。当它达到 `--context-compact-at`（默认 8192 个估算 token，包括开头的摘要）时，模型会写一份简短的交接报告，约 300 个 token：到目前为止的人名、语域和术语决定。这份报告作为下一个窗口的开头。最新的一份保存在 `<book>_handoff.md` 里，`--resume` 会把它读回来。这个默认值是为连贯性选的，而不是为价格；测量数据见[会话压缩预算](../evaluation/session-compact-budget.md)，其中也说明了更低的值其实更便宜。

## 准备

不需要安装任何东西。会话模式需要：

- 一本 EPUB、Markdown 或 PDF 书（PDF 两条路线都行：`--to-epub` 翻译的是 Markdown，纯文本路线也接受会话）。TXT 和 SRT 从不把上下文交给模型。
- 一条能保留历史的路线：OpenAI 系路线（OpenAI、网关、groq、xai、litellm、本地服务器）和 anthropic。Gemini 和 Qwen 自己维护历史，用的是不带值的 `--use_context`，会拒绝 `session`。codex 路线不管你要不要，都是会话：它的线程就是历史。

参数：

| 参数 | 作用 |
|---|---|
| `--use_context session` | 开启会话模式。 |
| `--context-compact-at N` | 窗口预算，以估算 token 计，包括交接报告。默认 8192，最小 1500。如果你的模型输入上限更小，就设成那个上限。 |
| `--no-context-compact` | 从不要求写交接报告。窗口仍在达到预算时滚动，但下一个窗口从空白开始。 |
| `--glossary FILE` | 固定你能负责的译法（`term -> translation` 形式的行）。只有出现在某个请求里的术语才会随它发送。 |
| `--glossary-auto on` | 同时保留交接报告里确立的译名。默认关闭。它需要一个会用名字而不是大段文字作答的模型。 |

会话模式与 `--parallel-workers` 同用会被拒绝（一份历史无法在多个 worker 之间共享），与 `--model_list` 同用也会被拒绝（每个模型有自己的缓存，而且一段对话会由几个模型轮流书写）。`--context_paragraph_limit` 属于窗口模式，在这里会被忽略。

## 推荐命令

翻译小说时，加上 `--use_context session`；如果某个反复出现的人名在窗口接缝之后译法漂移了，再加上 `--glossary names.txt`。如果模型在本地运行、上下文较小，就把 `--context-compact-at` 设为它的输入上限。如果端点没有提示缓存，就改用不带值的 `--use_context`。按书的类型、端点和系统列出的完整清单见 [EPUB 推荐设置](recommended-epub.md)；PDF 见 [PDF 推荐设置](recommended-pdf.md)。

## 可能出现的问题

- **`session: compacting at 8192 estimated tokens (the default; --context-compact-at overrides)`**。不是问题，运行只是告诉你它用的预算。
- **进度条上的 `cached=` 在十几个请求之后仍然是 0。**端点没有提示缓存，每个请求都在按全价为整段历史付费。按 Ctrl+C，改用不带值的 `--use_context` 重新运行。
- **`--use_context session outside plan mode leaves grouping off, so every paragraph is its own request and each one re-reads the whole history.`** 不在计划模式下的 EPUB（例如 `--plan-classify none`），或者 codex 路线上的 SRT 书。调高 `--accumulated_num`。Markdown 和 PDF 运行不会打印这一行；在那里，调大 `--batch_size` 可以减少请求数。
- **`Error: --use_context session is not implemented for the gemini format; it would be accepted and ignored.`** 在 Gemini 和 Qwen 上使用不带值的 `--use_context`。
- **`--use_context session is not supported for txt books; it will be ignored.`** TXT 和 SRT 不携带上下文。
- **`--parallel-workers is not supported with --use_context session: one history is the context, and a worker cannot share it.`** 二选一。不带值的 `--use_context` 可以保留多个 worker。
- **`a compact budget of N is too small for a session; use at least 1500 estimated tokens.`** 低于 1500 时，一个窗口的大部分都是开头的那份交接报告。如果你想要比这更少的上下文，就用不带值的 `--use_context`。
- **`ℹ handoff report failed (…); starting the next context window without a summary`**，或 `… keeping the current context and retrying on the next paragraph`。某次压缩没有产出可用的报告。翻译会继续。如果接缝之后的文字出现漂移，就用 `--glossary` 固定术语。
- **`--glossary-auto on learns renderings from the handoff report a session writes when it compacts, and this run keeps no session to compact.`** 加上 `--use_context session`，或者改用不需要会话的 `--glossary`。
- **小模型压缩之后文字出现漂移。**交接报告由模型来写，小模型可能写得不好。用 `--glossary` 固定人名，或者传入 `--no-context-compact`，接受每个接缝处从空白窗口开始。
- **`--context-compact-at` 或 `--no-context-compact` 被打印为 `only applies to --use_context session; ignoring it.`** 你给了压缩参数，却没有开启会话模式。

---

### Zh Formats Epub (`docs/zh/formats/epub.md`)

# EPUB

如果你想要一本 EPUB 的双语版，大多数人需要的就是这条命令：

```bash
bbook_maker --book_name my_book.epub --language zh-hans --use_context session --quiet
```

它会在输入文件旁边生成 `my_book_bilingual.epub`。遇到教材、小模型或本地服务器时要改什么，见 [EPUB 推荐设置](../features/recommended-epub.md)。

EPUB 是本工具最熟悉的格式，所有功能都能用在它上面。

## 文件是怎么读取的

EPUB 是一个由 XHTML 页面组成的 zip 包。工具会读取书的 spine 里列出的每一页。

- **使用大模型路线时（默认）：**书会经过[计划模式](../features/plan-mode.md)。加载器找出每一个承载文字的块（段落、标题、列表项、表格单元格、引文、诗行、图题），按类型分组，再问模型哪几类值得翻译。答案保存在 `<book>_plan.json` 里，之后的运行会复用它。随后，相邻的块在 token 预算和单元数上限之内合成一次请求。
- **使用翻译服务路线且没有 `--classify-model`，或使用 `--plan-classify none` 时：**只翻译 `--translate-tags` 选中的标签，默认是 `<p>`。这时 `<p>` 之外的诗歌或表格会保留源语言。
- 段落里的链接、强调和其他行内标记在翻译前被替换成编号标记，翻译后再放回原处，所以模型永远看不到原始 HTML。
- `--exclude-translate-tags` 里的内容（默认 `sup` 和 `code`）从不发送。

## 你会得到什么

- 输入文件旁边的 `<name>_bilingual.epub`。每段译文紧跟在它的原文后面，使用相同的标签和 class。id 和内部链接都会保留。
- 书籍简介下方的一行署名，内容为“Translated by \<model\>, \<year\>.”；`--no_disclosure` 可以去掉它。
- 计划模式和会话模式的运行会在书里写入 `bbm_translation_metadata.json`：模型、日期，以及 `--glossary` 文件的校验和。
- 输入文件旁边的 `<book>_plan.json`（计划），以及会话模式运行时的 `<book>_handoff.md`（最新的交接报告）。
- 按 Ctrl+C 或出错时：`<name>_bilingual_temp.epub` 和一个检查点。加上 `--resume` 重新运行即可。

## 推荐命令

```bash
bbook_maker \
  --book_name my_book.epub \
  --language zh-hans \
  --use_context session \
  --quiet
```

遇到不寻常的书（教材、双语版、正文不在 `<p>` 里的书），先预览计划。这一步不需要 key，也不翻译任何内容：

```bash
bbook_maker \
  --book_name my_book.epub \
  --plan-dry-run
```

## 适用的参数

### 路线与运行

这些参数在所有格式上作用相同。

| 参数 | 作用 |
|---|---|
| `--book_name PATH` | 要翻译的文件。扩展名决定格式。 |
| `-m`, `--model MODEL` | 模型 id，按端点自己的写法。openai 格式下默认 `gpt-6-luna`。 |
| `--key KEY` | API key；用逗号分隔多个 key 可以轮换使用。未提供时依次读取 `BBM_API_KEY` 和该格式自己的变量。 |
| `--api_base URL` | 端点地址。默认是该格式的官方地址。 |
| `--api_format FORMAT` | 端点所用的 API，或一个翻译服务。省略时根据 `--api_base` 推断。 |
| `--provider NAME` | `bbm_providers.json` 里的一个具名端点。 |
| `--model_list IDS` | 轮换使用的多个模型。与 `--use_context session` 同用会被拒绝。 |
| `--language LANGUAGE` | 目标语言：标签、语言名，或 `TAG:NAME`。默认 `zh-hans`。 |
| `--source_lang LANGUAGE` | 明确指定源语言。会写进每个大模型提示词；在 `qwen` 和 `customapi` 上作为请求字段发送。 |
| `--prompt VALUE_OR_FILE` | 自定义提示词：`user` 模板（必须包含 `{text}`）、`system`、`style`。 |
| `--temperature FLOAT` | 采样温度，仅用于接受该参数的格式。 |
| `--no-thinking` | 让模型回答前不要先推理。在 OpenAI 形态的路线上，请求字段通过协商确定；在 anthropic 上是 `thinking: disabled`；在 codex 上会被拒绝。 |
| `--extra_body JSON` | 在 openai 和 anthropic 路线上附加的请求体字段。 |
| `--extra_headers JSON` | 在 openai 和 anthropic 路线上附加的 HTTP 头。 |
| `--interval SECONDS` | 请求之间的间隔。只有 gemini 格式使用它。 |
| `-p`, `--proxy URL` | 本次运行使用的 HTTP 代理。 |
| `--test` | 只翻译开头几段。 |
| `--test_num N` | 配合 `--test`，翻译多少段（默认 10）。 |
| `--resume` | 从检查点继续一次中断的运行。 |
| `--single_translate` | 只写出译文，不带原文。 |

### EPUB 参数

| 参数 | 作用 |
|---|---|
| `--translate-tags TAGS` | 没有计划时要翻译的标签（默认 `p`）。计划模式下忽略。 |
| `--exclude-translate-tags TAGS` | 其内容从不翻译的标签（默认 `sup,code`；传 `""` 清空）。 |
| `--allow_navigable_strings` | 连不在任何标签里的文字也一起翻译。计划模式下多余。 |
| `--only_filelist FILES` | 只翻译这些内部文件（相对于 OPF 的名字，逗号分隔）。 |
| `--exclude_filelist FILES` | 跳过这些内部文件。给了 `--only_filelist` 时忽略。 |
| `--plan-classify MODE` | 计划由谁决定：`auto`（默认）、`none`、`all`、`model`、`agent`。见[计划模式](../features/plan-mode.md)。 |
| `--classify-model MODEL` | 用另一个模型分类（默认：提供方条目的 `classify_model`，其次是翻译用的模型）。在命令行上写明时，运行进入 `model` 模式，分类失败会中止运行；它也能让翻译服务路线用上计划。`--plan-classify-model` 是旧名字。在 `--plan-classify all` 或 `agent` 下忽略。 |
| `--classify-base-url URL` | 该模型的服务地址，当它不是本次运行的端点时使用（仅限 OpenAI 兼容）。 |
| `--classify-key KEY` | `--classify-base-url` 使用的 key。见[哪个 key 发往哪里](../providers.md#哪个-key-发往哪里)。 |
| `--plan-dry-run` | 打印计划并写出 `<book>_plan.json`，不翻译。不需要 key。 |
| `--plan-min-coverage FRACTION` | 计划覆盖的文字比例低于这个值时中止（默认 0.5）。 |
| `--poetry-group-size N` | 已弃用；仍然可用，但会警告。请改用 `--max-batch-units`。 |
| `--accumulated_num N` | 计划模式下是每个请求的 token 预算（不设时自动推算；`1` 关闭合并）。没有计划时，是每个请求累积的字符数。 |
| `--max-batch-units N` | 计划模式：一个请求最多包含的单元数（默认 16；在不支持严格 schema 的端点上为 8）。 |
| `--block_size N` | 没有计划时：把段落合并成用分隔符方式翻译的块。`--accumulated_num` 大于 1 时忽略。 |
| `--sentence_mode` | 逐句翻译。不能与计划同用；`--accumulated_num` 大于 1 时忽略。 |
| `--use_context [window\|session]` | 带上前文：不带值或 `window` 会重发最近几对原文与译文；`session` 保持一份历史。见[会话模式](../features/session-mode.md)。 |
| `--context_paragraph_limit N` | 窗口模式：重发多少对。 |
| `--context-compact-at N` | 会话模式：历史达到这么多估算 token 时压缩（默认 8192，最小 1500）。 |
| `--no-context-compact` | 会话模式：窗口滚动时不写交接报告。 |
| `--glossary FILE` | `term -> translation` 形式的固定译法，只随包含这些术语的请求发送。适用于 openai 系和 codex 路线。`--terminology` 是同一个参数。 |
| `--glossary-auto on\|off` | 保留会话的交接报告里确立的译名。除非你要求，否则关闭。 |
| `--parallel-workers N` | 同时翻译几个章节（2 到 4 比较有用）。与会话模式同用或在 codex 上会被拒绝。 |
| `--translation_style CSS` | 译文段落的 CSS。 |
| `--translation_color COLOR` | 只设颜色；`--translation_style` 优先。 |
| `--no_disclosure` | 去掉那一行 AI 翻译署名，同时不写元数据文件。 |
| `--translation-metadata` | 在普通的标签模式运行中也写入 `bbm_translation_metadata.json`（计划模式和会话模式的运行本来就会写）。 |
| `--retranslate OUT FILE START END` | 重新翻译一本已有双语 EPUB 中的一段范围。 |
| `--quiet` | 不显示进度条，也不回显段落；报告和错误照常打印。 |

## 不适用于本格式

- `--batch_size`：EPUB 加载器用 `--accumulated_num` 合并请求；运行会给出警告。
- `--batch`、`--batch-use`：在 EPUB 上会被拒绝，因为那里永远走不到 Batch API 的路径。
- `--to-epub`、`--pdf-ocr`、`--ocr-replace-layer`、`--device`、`--ocr-lang`、`--pages`、`--no-formula-images`、`--pdf_layout`、`--img-model`、`--img-base-url`、`--img-key`：仅限 PDF。在 EPUB 上用 `--to-epub` 会中止运行；其他参数会给出警告或不起作用（三个图像参数：运行会警告只有 PDF 路线才有图像步骤）。

---

### Zh Formats Md (`docs/zh/formats/md.md`)

# Markdown

Markdown 文件按正文块逐块翻译。代码、表格和 front matter 原样保留。

## 文件是怎样读取的

- YAML front matter、围栏代码块、表格以及不含正文的行都原样通过，不翻译。
- 标题和段落是翻译块。
- 这些块按 `--batch_size`（默认 10）分批发送，一批在大约 2000 个字符处也会截止。批次上方的标题路径会作为面包屑一起发送，让模型知道自己在哪一节。
- `--use_context` 在这里可用，窗口模式和会话模式都行。

这也是 [PDF 转双语 EPUB](../features/pdf-to-epub.md) 背后的加载器：PDF 先被转成 Markdown，再在这里翻译。

## 你会得到什么

- 输入文件旁边的 `<name>_bilingual.md`：每一块后面紧跟它的译文。使用 `--single_translate` 时只有译文。
- 会话模式的运行还会生成 `<name>_handoff.md`，里面是最新的交接报告。
- 按 Ctrl+C 或出错时：`<name>_bilingual_temp.txt` 和一个检查点。用 `--resume` 重跑。

## 推荐命令

```bash
bbook_maker \
  --book_name my_doc.md \
  --language zh-hans \
  --prompt prompt_md.json \
  --use_context session \
  --batch_size 20
```

仓库里的 `prompt_md.json` 是专为 Markdown 写的提示词：它要求模型保留标记。Markdown 没有计划模式，所以在会话模式下，每个请求都会重读历史。调大 `--batch_size`，就能用更少、更大的请求发送。

## 适用的参数

### 路线与运行

这些参数在所有格式上的作用都一样。

| 参数 | 作用 |
|---|---|
| `--book_name PATH` | 要翻译的文件。扩展名决定格式。 |
| `-m`, `--model MODEL` | 模型 ID，按端点自己的写法原样填写。openai 格式下默认 `gpt-6-luna`。 |
| `--key KEY` | API key；用逗号分隔的多个 key 会轮流使用。不写时先读 `BBM_API_KEY`，再读该格式自己的变量。 |
| `--api_base URL` | 端点地址。默认是该格式的官方地址。 |
| `--api_format FORMAT` | 端点使用的 API，或者一个翻译服务。不写时根据 `--api_base` 推断。 |
| `--provider NAME` | `bbm_providers.json` 中的一个具名端点。 |
| `--model_list IDS` | 轮流使用的多个模型。与 `--use_context session` 同用时会被拒绝。 |
| `--language LANGUAGE` | 目标语言：一个标签、一个名称，或 `TAG:NAME`。默认 `zh-hans`。 |
| `--source_lang LANGUAGE` | 明确给出源语言。会写进每一个大模型提示词；在 `qwen` 和 `customapi` 上作为一个字段发送。 |
| `--prompt VALUE_OR_FILE` | 自定义提示词：`user` 模板（必须包含 `{text}`）、`system`、`style`。 |
| `--temperature FLOAT` | 采样温度，用于接受这一参数的格式。 |
| `--no-thinking` | 要求模型回答之前不做推理。在 OpenAI 形式的路线上，这个字段会自动协商；anthropic 上是 `thinking: disabled`；codex 上会被拒绝。 |
| `--extra_body JSON` | openai 和 anthropic 路线上额外的请求体字段。 |
| `--extra_headers JSON` | openai 和 anthropic 路线上额外的 HTTP 头。 |
| `--interval SECONDS` | 请求之间的间隔。只有 gemini 格式使用它。 |
| `-p`, `--proxy URL` | 本次运行使用的 HTTP 代理。 |
| `--test` | 只翻译开头的若干段。 |
| `--test_num N` | 配合 `--test`：翻译多少段（默认 10）。 |
| `--resume` | 从检查点继续一次中断的运行。 |
| `--single_translate` | 只写出译文，不带原文。 |

### Markdown 参数

| 参数 | 作用 |
|---|---|
| `--batch_size N` | 一个请求发送的正文块数（默认 10；一批在大约 2000 个字符处也会截止）。 |
| `--use_context [window\|session]` | 携带前面的段落：不带值或 `window` 会重发几对原文与译文；`session` 保持一份历史。 |
| `--context_paragraph_limit N` | 窗口模式：重发多少对原文与译文。 |
| `--context-compact-at N` | 会话模式：估算 token 数达到这个值时压缩（默认 8192，最小 1500）。 |
| `--no-context-compact` | 会话模式：滚动到下一个窗口时不写交接报告。 |
| `--glossary FILE` | `term -> translation` 形式钉住的译法（openai 形式的路线和 codex 路线）。`--terminology` 是同一个参数。 |
| `--glossary-auto on\|off` | 保留会话交接报告中确立的译名。除非明确要求，否则关闭。 |
| `--parallel-workers N` | 同时翻译多个批次或分段。与会话同用、以及在 codex 上都会被拒绝。 |

## 不适用于这种格式的参数

- `--accumulated_num`：Markdown 加载器不读它；运行时会给出警告，并提示改用 `--batch_size`。在计划模式之外，会话每个请求都会重读历史，所以更大的 `--batch_size` 能让请求数量少一些。
- `--max-batch-units`、`--plan-classify`、`--plan-dry-run`、`--plan-min-coverage`、`--poetry-group-size`：计划模式只用于 EPUB。
- `--classify-model`、`--classify-base-url`、`--classify-key`（以及旧名称 `--plan-classify-model`）：这种格式上暂时没有任何东西做分类。运行时会警告该参数被忽略。
- `--exclude-translate-tags`：接受且不给警告，但 Markdown 加载器不读它。
- `--translate-tags`、`--allow_navigable_strings`：EPUB 标记选择器；运行时会给出警告。
- `--only_filelist`、`--exclude_filelist`：EPUB 内部文件；忽略。
- `--block_size`、`--sentence_mode`：仅用于 EPUB；忽略。
- `--translation_style`、`--translation_color`：Markdown 没有样式；运行时会给出警告。
- `--no_disclosure`、`--translation-metadata`：输出不带署名行，也不带元数据文件；运行时会给出警告。
- `--retranslate`：仅用于 EPUB；会被拒绝。
- `--quiet`：仅用于 EPUB；运行时会给出警告。
- `--batch`、`--batch-use`：Markdown 加载器没有实现 Batch API。
- `--to-epub`、`--pdf-ocr`、`--ocr-replace-layer`、`--device`、`--ocr-lang`、`--pages`、`--no-formula-images`、`--pdf_layout`、`--img-model`、`--img-base-url`、`--img-key`：仅用于 PDF。在这种格式上，`--to-epub` 会让运行停止；其余的会给出警告或不起作用（三个图像参数：运行时会警告只有 PDF 路线有图像这一步）。

---

### Zh Formats Pdf (`docs/zh/formats/pdf.md`)

# PDF

PDF 可以走两条路线。除非你只想要纯文本，否则选第一条。

| 路线 | 参数 | 输出 | 需要 |
|---|---|---|---|
| **PDF 转双语 EPUB** | `--to-epub` | 带目录的可重排 EPUB，插图和行间公式保留为图片 | [PDF 扩展](../installation-pdf.md)和 Pandoc 3.1.12+ |
| **纯文本路线**（旧） | 无 | 一个双语 `.txt`，另外尝试生成一个 EPUB | 基础安装 |

## `--to-epub` 路线

### 文件是怎样读取的

1. [docling](https://github.com/docling-project/docling) 用它的版面模型和表格模型读取 PDF 的文字层，写出 Markdown：`<name>_book/source.md` 和图片。
2. 没有文字层的页面（扫描件）会被拒绝，除非你传入 `--pdf-ocr`，这时由 OCR 模型读取它们。有文字层的页面保留自己的文字层，除非你同时传入 `--ocr-replace-layer`：这时每一页都做 OCR，PDF 自带的文字层不再使用。
3. 使用 `--img-model` 时，视觉模型会根据页面图像，纠正 docling 给页面各区域定的角色（标题、图题、脚注、代码）。
4. 标题层级从页面上读出（先看编号，再看字号和字重）。行间公式从页面上裁成图片。
5. [Markdown 加载器](md.md)把 `source.md` 翻译成 `book_bilingual.md`。
6. Pandoc 生成 EPUB。它的导航按标题生成。

重跑同一条命令会复用提取结果和已完成的翻译。如果有内容出错，在翻译开始之前编辑 `source.md`。删除 `book_bilingual.md` 即可重新翻译。推荐的命令和要留意的终端信息见 [PDF 转双语 EPUB](../features/pdf-to-epub.md)。

### 你会得到什么

- PDF 旁边的 `<name>_book/`：`source.md`、`images/`、`book_bilingual.md`、一份清单，以及 `.work/`。
- PDF 旁边的 `<name>_bilingual.epub`，是成书的一份副本。
- `source.md` 中，每一页开头都有一条 `` 注释，编号与 PDF 文件中的一致（从 1 开始，与 `--pages` 的计数相同），空白页也包括在内。docling 没有给出页码的条目（很少见）放在最后，位于 `` 下面；运行时会说明有多少条。
- 使用 `--pages 12-30` 时：`<name>_pages-12-30_book/` 和 `<name>_pages-12-30_bilingual.epub`，所以某一章永远不会覆盖整本书。

### 适用的参数

这条路线自己的参数：

| 参数 | 作用 |
|---|---|
| `--to-epub` | 走这条路线。 |
| `--pdf-ocr` | 用 OCR 模型读取没有文字层的页面。默认关闭；不开的话，这些页面会被拒绝。 |
| `--ocr-replace-layer` | 配合 `--pdf-ocr`：对每一页做 OCR，用识别结果代替 PDF 的文字层。默认关闭：保留已有的文字层，只对没有文字层的页面做 OCR。适用于有误的文字层（别的语言、乱码）；在干净的扫描件上，文字层读得更好。切换它会重新读取 PDF。 |
| `--ocr-lang LANGS` | 配合 `--pdf-ocr`：OCR 引擎要识别的语言，用逗号分隔，写引擎自己的代码（rapidocr：`ch`、`en`、`latin`；easyocr：`ch_sim`、`ja`、`ko`；ocrmac：`zh-Hans`、`ja-JP`），或在 BCP-47 标签前加 `iso:`（`iso:zh`、`iso:ja`），所有引擎都接受。rapidocr 只用第一种语言，而且拒绝 `ch_sim`：中文请写 `iso:zh`。 |
| `--device auto\|cpu\|cuda\|mps\|xpu` | 提取模型在哪里运行。`auto`（默认）会检测加速器，找不到就退回 CPU。CPU 的输出完全一样，只是更慢。 |
| `--pages PAGES` | 只处理这些页，从 1 开始编号（`12-30`、`1,3,5-7`）。 |
| `--no-formula-images` | 把行间公式留作 `` 占位符，而不是图片。 |
| `--img-model MODEL` | 根据页面图像纠正区域角色的视觉模型。只有在这里指定，或在提供方条目的 `img_model` 中指定时才开启（自带的 `openai` 条目指定了一个）；`none` 可以关掉它。绝不会退而使用翻译模型。每页约 3,000 个提示 token。 |
| `--img-base-url URL` | 这个模型所在的地址，当它不在本次运行的端点上时使用（仅限 OpenAI 兼容接口）。 |
| `--img-key KEY` | `--img-base-url` 所用的 key；默认在本次运行自己的端点上使用本次运行的 key。 |
| `--quiet` | 提取期间不显示实时进度行。 |

其他所有参数都原样传给 Markdown 翻译。下面这些在那里会起作用：

| 参数 | 作用 |
|---|---|
| `--book_name PATH` | 要翻译的文件。扩展名决定格式。 |
| `-m`, `--model MODEL` | 模型 ID，按端点自己的写法原样填写。openai 格式下默认 `gpt-6-luna`。 |
| `--key KEY` | API key；用逗号分隔的多个 key 会轮流使用。不写时先读 `BBM_API_KEY`，再读该格式自己的变量。 |
| `--api_base URL` | 端点地址。默认是该格式的官方地址。 |
| `--api_format FORMAT` | 端点使用的 API，或者一个翻译服务。不写时根据 `--api_base` 推断。 |
| `--provider NAME` | `bbm_providers.json` 中的一个具名端点。 |
| `--model_list IDS` | 轮流使用的多个模型。与 `--use_context session` 同用时会被拒绝。 |
| `--language LANGUAGE` | 目标语言：一个标签、一个名称，或 `TAG:NAME`。默认 `zh-hans`。 |
| `--source_lang LANGUAGE` | 明确给出源语言。会写进每一个大模型提示词；在 `qwen` 和 `customapi` 上作为一个字段发送。 |
| `--prompt VALUE_OR_FILE` | 自定义提示词：`user` 模板（必须包含 `{text}`）、`system`、`style`。 |
| `--temperature FLOAT` | 采样温度，用于接受这一参数的格式。 |
| `--no-thinking` | 要求模型回答之前不做推理。在 OpenAI 形式的路线上，这个字段会自动协商；anthropic 上是 `thinking: disabled`；codex 上会被拒绝。 |
| `--extra_body JSON` | openai 和 anthropic 路线上额外的请求体字段。 |
| `--extra_headers JSON` | openai 和 anthropic 路线上额外的 HTTP 头。 |
| `--interval SECONDS` | 请求之间的间隔。只有 gemini 格式使用它。 |
| `-p`, `--proxy URL` | 本次运行使用的 HTTP 代理。 |
| `--test` | 只翻译开头的若干段。 |
| `--test_num N` | 配合 `--test`：翻译多少段（默认 10）。 |
| `--resume` | 从检查点继续一次中断的运行。 |
| `--single_translate` | 只写出译文，不带原文。 |
| `--batch_size N` | 每个请求包含的正文块数（默认 10）。 |
| `--use_context [window\|session]` | 携带前文。推荐 `session`：PDF 会被提取成许多短小的文本块。 |
| `--context_paragraph_limit N` | 窗口模式：重发多少对原文与译文。 |
| `--context-compact-at N` | 会话模式：估算 token 数达到这个值时压缩（默认 8192，最小 1500）。 |
| `--no-context-compact` | 会话模式：滚动到下一个窗口时不写交接报告。 |
| `--glossary FILE` | `term -> translation` 形式钉住的译法（openai 形式的路线和 codex 路线）。 |
| `--glossary-auto on\|off` | 保留交接报告中确立的译名。只在发生压缩时学习，所以一篇短论文在默认预算下什么也学不到。 |
| `--parallel-workers N` | 同时处理多个批次。与会话同用时会被拒绝。 |

这条路线的旧写法 `--with-ocr` 和 `--no-gpu` 在一个发布周期内仍然有效，分别等同于 `--pdf-ocr` 和 `--device cpu`，并会打印一条提示。它们不出现在 `--help` 里。

### 不适用于 `--to-epub` 路线的参数

- `--no_disclosure`：暂不支持；署名行总会加上。
- `--translation-metadata`：这个 EPUB 由 Pandoc 生成，不带元数据文件，也不嵌入术语表。
- `--pdf_layout`：属于纯文本路线。
- `--accumulated_num`、`--max-batch-units` 以及计划模式参数（`--plan-classify`、`--plan-dry-run`、`--plan-min-coverage`）：计划模式只用于 EPUB；Markdown 加载器用 `--batch_size` 分组。
- `--classify-model`、`--classify-base-url`、`--classify-key`：这条路线上暂时没有任何东西做分类。运行时会警告该参数被忽略。
- `--translate-tags`、`--exclude-translate-tags`、`--allow_navigable_strings`、`--only_filelist`、`--exclude_filelist`、`--block_size`、`--sentence_mode`、`--translation_style`、`--translation_color`、`--retranslate`：仅用于 EPUB 输入。
- `--batch`、`--batch-use`：这条路线没有实现。

## 纯文本路线（不加 `--to-epub`）

### 文件是怎样读取的

每一页的文字由 PyMuPDF 取出，再切分成行。这些行按 `--batch_size`（默认 10）分组发送；开启 `--use_context`（window 或 `session`）时带上前文，使用 `--glossary` 时带上钉住的译法。没有任何结构：没有标题，没有插图，没有表格。

### 你会得到什么

- PDF 旁边的 `<name>_bilingual.txt`。
- 用同样的文字尝试生成 `<name>_bilingual.epub`。如果失败，TXT 仍然保留，所以不会重复翻译。
- 使用 `--pdf_layout` 时：`<name>_bilingual_<layout>.pdf`，对应 `top-bottom`、`side-by-side`，或用 `all` 两者都生成。这需要 `reportlab` 包，它不在依赖列表里；没有它时，运行会打印 `pdf creation skipped: install reportlab first` 并继续。
- 按 Ctrl+C 或出错时：`<name>_bilingual_temp.txt`。用 `--resume` 重跑。

```bash
bbook_maker \
  --book_name paper.pdf \
  --language zh-hans \
  --batch_size 20
```

### 适用的参数

上表中的路线参数和运行参数，另外还有：

| 参数 | 作用 |
|---|---|
| `--batch_size N` | 每个请求包含的行数（默认 10）。 |
| `--pdf_layout none\|top-bottom\|side-by-side\|all` | 另外按这种版式写出一个双语 PDF（需要 `reportlab`）。 |

### 不适用于纯文本路线的参数

- `--parallel-workers`：运行保持串行。运行时会给出警告。
- `--pdf-ocr`、`--ocr-replace-layer`、`--device`、`--ocr-lang`、`--pages`、`--no-formula-images`：仅用于 `--to-epub`。运行时会给出警告，并读取整个文件。
- `--img-model`、`--img-base-url`、`--img-key`：图像这一步属于 `--to-epub`。运行时会给出警告。
- `--classify-model` 及其两个配套参数：这里没有任何东西做分类。运行时会给出警告。
- 上面为 `--to-epub` 路线列出的所有仅限 EPUB 的参数，以及 `--accumulated_num`、`--quiet`、`--no_disclosure`、`--translation-metadata`。

---

### Zh Formats Srt (`docs/zh/formats/srt.md`)

# SRT

字幕逐块翻译。每一块都保留它的序号和时间轴。

## 文件是怎样读取的

- 文件在空行处切分成块。一块由一个序号、一条时间轴和一行或多行文字组成。
- 默认每一块单独发一个请求。`--accumulated_num N` 把相邻的块放进同一个请求，最多 `N` 个字符（上限 512）。
- 加载器自带的提示词会告诉模型不要动每一块的序号和时间轴。如果你用 `--prompt` 替换了 `user` 模板，也要在你的模板里写明这一点，否则回答无法按 SRT 解析。运行时会就此给出警告。

## 你会得到什么

- 输入文件旁边的 `<name>_bilingual.srt`。每一块都保留序号和时间轴，原文后面紧跟译文。使用 `--single_translate` 时只有译文。
- 按 Ctrl+C 或出错时：`<name>_bilingual_temp.srt` 和一个检查点。用 `--resume` 重跑。

## 推荐命令

```bash
bbook_maker \
  --book_name test_books/Lex_Fridman_episode_322.srt \
  --language zh-hans \
  --accumulated_num 400
```

## 适用的参数

### 路线与运行

这些参数在所有格式上的作用都一样。

| 参数 | 作用 |
|---|---|
| `--book_name PATH` | 要翻译的文件。扩展名决定格式。 |
| `-m`, `--model MODEL` | 模型 ID，按端点自己的写法原样填写。openai 格式下默认 `gpt-6-luna`。 |
| `--key KEY` | API key；用逗号分隔的多个 key 会轮流使用。不写时先读 `BBM_API_KEY`，再读该格式自己的变量。 |
| `--api_base URL` | 端点地址。默认是该格式的官方地址。 |
| `--api_format FORMAT` | 端点使用的 API，或者一个翻译服务。不写时根据 `--api_base` 推断。 |
| `--provider NAME` | `bbm_providers.json` 中的一个具名端点。 |
| `--model_list IDS` | 轮流使用的多个模型。与 `--use_context session` 同用时会被拒绝。 |
| `--language LANGUAGE` | 目标语言：一个标签、一个名称，或 `TAG:NAME`。默认 `zh-hans`。 |
| `--source_lang LANGUAGE` | 明确给出源语言。会写进每一个大模型提示词；在 `qwen` 和 `customapi` 上作为一个字段发送。 |
| `--prompt VALUE_OR_FILE` | 自定义提示词：`user` 模板（必须包含 `{text}`）、`system`、`style`。 |
| `--temperature FLOAT` | 采样温度，用于接受这一参数的格式。 |
| `--no-thinking` | 要求模型回答之前不做推理。在 OpenAI 形式的路线上，这个字段会自动协商；anthropic 上是 `thinking: disabled`；codex 上会被拒绝。 |
| `--extra_body JSON` | openai 和 anthropic 路线上额外的请求体字段。 |
| `--extra_headers JSON` | openai 和 anthropic 路线上额外的 HTTP 头。 |
| `--interval SECONDS` | 请求之间的间隔。只有 gemini 格式使用它。 |
| `-p`, `--proxy URL` | 本次运行使用的 HTTP 代理。 |
| `--test` | 只翻译开头的若干段。 |
| `--test_num N` | 配合 `--test`：翻译多少段（默认 10）。 |
| `--resume` | 从检查点继续一次中断的运行。 |
| `--single_translate` | 只写出译文，不带原文。 |

### SRT 参数

| 参数 | 作用 |
|---|---|
| `--accumulated_num N` | 一个请求发送的字幕块字符数（SRT 上限为 512）。 |

## 不适用于这种格式的参数

- `--batch_size`：SRT 加载器不读它；用 `--accumulated_num` 分组。
- `--use_context`：SRT 加载器在两种模式下都不会把上下文交给模型；运行时会给出警告。
- `--context_paragraph_limit`、`--context-compact-at`、`--no-context-compact`：上下文参数；这里没有东西读取它们。
- `--glossary`、`--glossary-auto`：只有 EPUB、Markdown 和 PDF 加载器会转发它们；运行时会给出警告。
- `--max-batch-units`、`--plan-classify`、`--plan-dry-run`、`--plan-min-coverage`、`--poetry-group-size`：计划模式只用于 EPUB。
- `--classify-model`、`--classify-base-url`、`--classify-key`（以及旧名称 `--plan-classify-model`）：这种格式上暂时没有任何东西做分类。运行时会警告该参数被忽略。
- `--translate-tags`、`--exclude-translate-tags`、`--allow_navigable_strings`：EPUB 标记选择器；运行时会给出警告。
- `--only_filelist`、`--exclude_filelist`：EPUB 内部文件；忽略。
- `--block_size`、`--sentence_mode`：仅用于 EPUB；忽略。
- `--parallel-workers`：SRT 的运行保持串行；运行时会给出警告。
- `--translation_style`、`--translation_color`：字幕没有样式；运行时会给出警告。
- `--no_disclosure`、`--translation-metadata`：输出不带署名行，也不带元数据文件；运行时会给出警告。
- `--retranslate`：仅用于 EPUB；会被拒绝。
- `--quiet`：仅用于 EPUB；运行时会给出警告。
- `--batch`、`--batch-use`：SRT 加载器没有实现 Batch API。
- `--to-epub`、`--pdf-ocr`、`--ocr-replace-layer`、`--device`、`--ocr-lang`、`--pages`、`--no-formula-images`、`--pdf_layout`、`--img-model`、`--img-base-url`、`--img-key`：仅用于 PDF。在这种格式上，`--to-epub` 会让运行停止；其余的会给出警告或不起作用（三个图像参数：运行时会警告只有 PDF 路线有图像这一步）。

---

### Zh Formats Txt (`docs/zh/formats/txt.md`)

# TXT

```bash
bbook_maker --book_name my_book.txt --language zh-hans --batch_size 20
```

这会在输入文件旁边写出 `my_book_bilingual.txt`。SRT 字幕和 Markdown 在下面各有自己的页面；PDF 有[单独的一节](../features/pdf-to-epub.md)。

纯文本文件按行分组逐组翻译。没有要保留的结构，所以可选项也不多。

## 文件是怎样读取的

- 文件按 UTF-8 读取，并切分成行。
- 这些行按 `--batch_size`（默认 10）分组发送，组内用换行符连接。
- 空的、只有空白或只有一个数字的组会原样复制，不发请求。
- 请求不携带任何前文：TXT 加载器没有上下文模式。

## 你会得到什么

- 输入文件旁边的 `<name>_bilingual.txt`：每组原文行后面紧跟它的译文。使用 `--single_translate` 时只有译文。
- 按 Ctrl+C 或出错时：`<name>_bilingual_temp.txt` 和一个检查点。用 `--resume` 重跑。

## 推荐命令

```bash
bbook_maker \
  --book_name test_books/the_little_prince.txt \
  --language zh-hans \
  --batch_size 20
```

`--batch_size` 越大，请求越少，每个请求的上下文越多。如果模型开始把行合并或漏掉，就把它调小。

## 适用的参数

### 路线与运行

这些参数在所有格式上的作用都一样。

| 参数 | 作用 |
|---|---|
| `--book_name PATH` | 要翻译的文件。扩展名决定格式。 |
| `-m`, `--model MODEL` | 模型 ID，按端点自己的写法原样填写。openai 格式下默认 `gpt-6-luna`。 |
| `--key KEY` | API key；用逗号分隔的多个 key 会轮流使用。不写时先读 `BBM_API_KEY`，再读该格式自己的变量。 |
| `--api_base URL` | 端点地址。默认是该格式的官方地址。 |
| `--api_format FORMAT` | 端点使用的 API，或者一个翻译服务。不写时根据 `--api_base` 推断。 |
| `--provider NAME` | `bbm_providers.json` 中的一个具名端点。 |
| `--model_list IDS` | 轮流使用的多个模型。与 `--use_context session` 同用时会被拒绝。 |
| `--language LANGUAGE` | 目标语言：一个标签、一个名称，或 `TAG:NAME`。默认 `zh-hans`。 |
| `--source_lang LANGUAGE` | 明确给出源语言。会写进每一个大模型提示词；在 `qwen` 和 `customapi` 上作为一个字段发送。 |
| `--prompt VALUE_OR_FILE` | 自定义提示词：`user` 模板（必须包含 `{text}`）、`system`、`style`。 |
| `--temperature FLOAT` | 采样温度，用于接受这一参数的格式。 |
| `--no-thinking` | 要求模型回答之前不做推理。在 OpenAI 形式的路线上，这个字段会自动协商；anthropic 上是 `thinking: disabled`；codex 上会被拒绝。 |
| `--extra_body JSON` | openai 和 anthropic 路线上额外的请求体字段。 |
| `--extra_headers JSON` | openai 和 anthropic 路线上额外的 HTTP 头。 |
| `--interval SECONDS` | 请求之间的间隔。只有 gemini 格式使用它。 |
| `-p`, `--proxy URL` | 本次运行使用的 HTTP 代理。 |
| `--test` | 只翻译开头的若干段。 |
| `--test_num N` | 配合 `--test`：翻译多少段（默认 10）。 |
| `--resume` | 从检查点继续一次中断的运行。 |
| `--single_translate` | 只写出译文，不带原文。 |

### TXT 参数

| 参数 | 作用 |
|---|---|
| `--batch_size N` | 一个请求发送的行数（默认 10）。 |

## 不适用于这种格式的参数

- `--use_context`：TXT 加载器在两种模式下都不会把上下文交给模型。运行时会给出警告；对会话模式，它打印 `--use_context session is not supported for txt books; it will be ignored.`
- `--context_paragraph_limit`、`--context-compact-at`、`--no-context-compact`：上下文参数；这里没有东西读取它们。
- `--glossary`、`--glossary-auto`：只有 EPUB、Markdown 和 PDF 加载器会转发它们；运行时会给出警告。
- `--accumulated_num`：TXT 加载器不读它；运行时会给出警告，并提示改用 `--batch_size`。
- `--max-batch-units`、`--plan-classify`、`--plan-dry-run`、`--plan-min-coverage`、`--poetry-group-size`：计划模式只用于 EPUB。
- `--classify-model`、`--classify-base-url`、`--classify-key`（以及旧名称 `--plan-classify-model`）：这种格式上暂时没有任何东西做分类。运行时会警告该参数被忽略。
- `--translate-tags`、`--exclude-translate-tags`、`--allow_navigable_strings`：EPUB 标记选择器；运行时会给出警告。
- `--only_filelist`、`--exclude_filelist`：EPUB 内部文件；忽略。
- `--block_size`、`--sentence_mode`：仅用于 EPUB；忽略。
- `--parallel-workers`：TXT 的运行保持串行；运行时会给出警告。
- `--translation_style`、`--translation_color`：纯文本没有样式；运行时会给出警告。
- `--no_disclosure`、`--translation-metadata`：输出不带署名行，也不带元数据文件；运行时会给出警告。
- `--retranslate`：仅用于 EPUB；会被拒绝。
- `--quiet`：仅用于 EPUB；运行时会给出警告。
- `--batch`、`--batch-use`：TXT 加载器没有实现 Batch API。
- `--to-epub`、`--pdf-ocr`、`--ocr-replace-layer`、`--device`、`--ocr-lang`、`--pages`、`--no-formula-images`、`--pdf_layout`、`--img-model`、`--img-base-url`、`--img-key`：仅用于 PDF。在这种格式上，`--to-epub` 会让运行停止；其余的会给出警告或不起作用（三个图像参数：运行时会警告只有 PDF 路线有图像这一步）。

---

--- METRICS ---
- Files Extracted: 71
- Estimated Token Budget: ~118613 tokens
- Canonical Reference: https://codewiki.google/github.com/yihong0618/bilingual_book_maker
