Metadata-Version: 2.4
Name: epub-dual-language
Version: 0.2.3
Summary: Create dual-language EPUB books with OpenRouter translations.
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: beautifulsoup4>=4.12.3
Requires-Dist: lxml>=5.2.1
Requires-Dist: python-dotenv>=1.0.1
Requires-Dist: requests>=2.32.3
Requires-Dist: tqdm>=4.66.4
Provides-Extra: dev
Requires-Dist: pytest>=8.2.0; extra == "dev"

# EPUB Dual Language

[![Plugin and package verification](https://github.com/OscarPellicer/epub-dual-language/actions/workflows/plugin.yml/badge.svg)](https://github.com/OscarPellicer/epub-dual-language/actions/workflows/plugin.yml)

<img src="calibre_plugin/images/icon.png" width="64" height="64" align="left" alt="EPUB Dual Language icon" style="margin-right: 20px;" />

EPUB Dual Language provides two ways to create a separate bilingual EPUB from an
existing EPUB: a command-line interface (CLI) and a Calibre desktop plugin. Both
interfaces use the same translation and EPUB-processing core, so the output behavior
is designed to remain consistent.

## Choose an interface

### CLI - overview

Use the `epub-dual-language` command when you want a scriptable or terminal-based
workflow. It accepts an EPUB path and target language, supports dry runs and detailed
translation options, and writes a separate output EPUB. The CLI uses your selected
provider credentials from the shell or a local `.env` file. See the [CLI installation](#installation), [CLI usage](#basic-usage) for installation, configuration, and usage.

### Calibre plugin - overview

Use the Calibre plugin when your books are already in a Calibre library. Select one
book with an EPUB format, choose `Dual Language` from the toolbar or book context menu,
and start the translation from the GUI. The plugin runs inside Calibre's bundled
Python runtime, performs work in the background, and adds the completed bilingual EPUB
as a separate Calibre book by default. It does not replace the original book, support
batch translation, or require a separate Python installation for normal use. See the [Calibre plugin guide](#calibre-plugin) for installation, configuration, and usage.

These are two interfaces to one shared core, not two different translation products.

## Features

- Translates EPUB books with OpenRouter chat models.
- Preserves the original EPUB container and stylesheet links.
- Produces a separate dual-language edition with updated title and EPUB identifiers.
- Supports paragraph or sentence interleaving.
- Supports chapter-based or token-budget chunking.
- Can translate only the beginning of a book for quick tests.
- Can add rare bracketed translator notes for important cultural or archaic references.
- Recovers completed items from truncated JSON responses instead of retranslating them.
- Isolates model-filtered passages by splitting failed batches.
- Retries temporary network, rate-limit, and server failures with backoff.
- Can use a fallback model for a passage rejected by the primary model.

Example of a generated dual-language EPUB (left) vs. original (right) opened in Calibre:

![example](media/calibre.png)

## Installation

Install from PyPI:

```bash
pip install epub-dual-language
```

Python 3.10 or newer is required.

For development, clone the repository and install it into a Python environment:

```bash
pip install -e ".[dev]"
```

## OpenRouter API Key

The CLI reads `OPENROUTER_API_KEY` from the environment. You can pass it directly when
you run the command.

macOS/Linux:

```bash
OPENROUTER_API_KEY=your_openrouter_key epub-dual-language "path/to/book.epub" "English"
```

PowerShell:

```powershell
$env:OPENROUTER_API_KEY="your_openrouter_key"; epub-dual-language "path/to/book.epub" "English"
```

Windows cmd:

```cmd
set OPENROUTER_API_KEY=your_openrouter_key && epub-dual-language "path/to/book.epub" "English"
```

You can also create a `.env` file in the directory where you run the command:

```env
OPENROUTER_API_KEY=your_openrouter_key
```

When you call `epub-dual-language`, it loads `.env` from the current working directory.
An environment variable already set in your shell takes priority over the `.env` value.
The repository's own `.env` is ignored by git.

## Basic Usage

```bash
epub-dual-language "path/to/book.epub" "English"
```

Write to a specific output path:

```bash
epub-dual-language "path/to/book.epub" "English" --output "output/book.dual.epub"
```

Translate only a small beginning sample:

```bash
epub-dual-language "path/to/book.epub" "English" --limit-units 30 --output "output/sample.dual.epub"
```

Run without calling OpenRouter, for testing:

```bash
epub-dual-language "path/to/book.epub" "English" --dry-run --limit-units 20
```

## Options

```bash
epub-dual-language INPUT_EPUB TARGET_LANGUAGE \
  --source-language German \
  --model google/gemini-3-flash-preview \
  --fallback-model provider/fallback-model \
  --api-attempts 3 \
  --api-timeout 120 \
  --chunking tokens \
  --max-tokens 4000 \
  --layout paragraph \
  --sentences-per-unit 5 \
  --translation-style italic-muted \
  --translator-notes / --no-translator-notes \
  --limit-units 30 \
  --output output/sample.dual.epub
```

Important options:

- `--model`: primary OpenRouter model.
- `--fallback-model`: optional model used only when one isolated passage fails with the primary model.
- `--api-attempts`: attempts for temporary connection, timeout, HTTP 408/429, or HTTP 5xx failures. Default: `3`.
- `--api-timeout`: timeout in seconds for each API request. Default: `120`.
- `--max-tokens`: estimated input budget for each initial translation batch. Default: `4000`.
- `--chunking`: `tokens` or `chapters`. Default: `tokens`.
- `--layout`: `paragraph` or `sentence`. Default: `paragraph`.
- `--sentences-per-unit`: with `--layout paragraph`, splits long paragraphs into groups of N consecutive sentences and interleaves each original group with its translation. Default: `5`.
- `--translation-style`: `italic`, `muted`, `italic-muted`, or `plain`. Default: `italic-muted`.
- `--translator-notes` / `--no-translator-notes`: permits rare, short translator notes. Default: enabled.
- `--limit-units`: translates only the first N extracted units.
- `--dry-run`: creates a sample EPUB without calling OpenRouter.

## Metadata

The generated EPUB is marked as a separate dual-language edition.

For example, an input title like:

```text
Original Title
```

becomes:

```text
Original Title (Dual English)
```

The OPF unique identifier is also replaced with a new deterministic dual-edition UUID. This reduces the chance that Calibre or an e-reader treats the translated edition as the same book as the source.

## Chunking

`--chunking tokens` is the default. It walks the EPUB in reading order and packs paragraphs or sentences into model requests until the estimated `--max-tokens` budget is reached. Small samples can therefore fit into a single API call even if they cross EPUB file boundaries.

`--chunking chapters` sends one EPUB spine document at a time. Many EPUBs store chapters as separate XHTML files, so this often approximates chapter-by-chapter translation. It is simple, but very long chapters can create large requests.

Token counts are estimates based on text-like tokens, not provider billing tokens.

Smaller values reduce the chance of truncated model output but create more API calls. A
value around `3000`–`4000` is a practical starting point for paragraph translation.

## API Failure Handling

The translator distinguishes between temporary service failures, malformed model output,
content filtering, and fatal API configuration errors.

| Failure | Behavior |
| --- | --- |
| Connection error or timeout | Retry the same batch with exponential backoff. |
| HTTP 408, 429, 500, 502, 503, or 504 | Retry the same batch with exponential backoff. |
| Truncated or malformed translation JSON | Keep every complete translation object that can be recovered, then request only unresolved items. |
| `content_filter`, model `error`, or unusable model output | Split the unresolved batch in half immediately and continue recursively. |
| One isolated passage still fails | Try `--fallback-model` when configured; otherwise insert an error placeholder and continue the EPUB. |
| Authentication, permission, invalid model, or other non-retryable HTTP error | Stop the run because splitting would not repair the request. |
| Temporary API failure after all configured attempts | Stop the run rather than fill the book with misleading placeholders. |

The placeholder written for an irreducible passage is:

```text
[TRANSLATION ERROR: This passage could not be translated.]
```

This behavior is designed for cases where a specific literary passage triggers a model
or provider filter. Successful passages are retained in their original order, and one
rejected passage does not prevent the rest of the EPUB from being generated.

`--api-attempts` applies only to failures likely to be temporary. Model-output failures
are not retried unchanged: they are recovered or split immediately.

Example using a fallback model:

```bash
epub-dual-language "book.epub" "Spanish" \
  --source-language French \
  --model google/gemini-3-flash-preview \
  --fallback-model provider/fallback-model \
  --chunking tokens \
  --max-tokens 4000
```

If a malformed response contains valid items 0 through 6 and truncates during item 7,
the translator keeps items 0 through 6 and requests only item 7 and the remaining
unresolved items. It does not pay to translate the successful prefix again.

## Layouts

`--layout paragraph --sentences-per-unit 5` is the default and recommended mode for reading long literary paragraphs:

```text
Original sentence group
Translated sentence group
Original sentence group
Translated sentence group
```

If a paragraph fits into one sentence group, the output remains simple paragraph alignment.

`--layout sentence` alternates original and translated sentences inside each paragraph. It can be useful for language study, but it is less pleasant for uninterrupted reading.

For very long paragraphs, use `--sentences-per-unit N` with `--layout paragraph` to translate smaller groups of consecutive sentences and render them as original group, translated group, original group, translated group:

```bash
epub-dual-language "book.epub" "Spanish" --layout paragraph --sentences-per-unit 5
```

## Translation Styling

Translated text is italic by default so it is easy to distinguish from the original without changing the book too aggressively.

```bash
epub-dual-language "path/to/book.epub" "English" --translation-style italic
```

Available styles:

- `italic`
- `muted`
- `italic-muted`
- `plain`

## Translator Notes

Translator notes are enabled by default. This lets the model add rare, short notes in square brackets for genuinely important archaic, historical, or culturally specific references:

```bash
epub-dual-language "path/to/book.epub" "English" --translator-notes
```

Disable them with `--no-translator-notes`.

The prompt explicitly asks the model not to explain ordinary lines or add notes everywhere.

## Calibre plugin

The repository also contains a focused Calibre InterfaceAction plugin. It creates a
separate dual-language EPUB from one selected Calibre book and uses the same EPUB
processor and provider client as the CLI. The original book is never modified. Batch
translation and non-EPUB formats are intentionally out of scope for the first version.

### Plugin installation and updating

![Calibre plugin 1](media/image.png)

![Calibre plugin 2](media/image2.png)

The easiest installation source is the ZIP built by GitHub:

1. Download `epub_dual_language_calibre_plugin.zip` from the
  [latest GitHub release](https://github.com/OscarPellicer/epub-dual-language/releases/latest).

2. In Calibre, choose **Preferences → Plugins → Load plugin from file**, select the plugin
ZIP, approve the third-party-plugin warning, and restart Calibre if requested. Updating
uses the same process with the newer ZIP. Plugin settings are stored in Calibre's plugin
configuration and are not reset by rebuilding or replacing the ZIP.

---

Every push and pull request automatically runs the Python tests, builds and validates
the Python package, builds and inspects the plugin ZIP, and uploads the ZIP/checksum as
a workflow artifact. CI then installs pinned Calibre 8.16.2 in an isolated directory,
installs the generated ZIP, verifies plugin registration, and creates a dry-run EPUB
through the installed plugin. No provider credentials or paid API requests are used.

The plugin is not an automatic installer. A future stable release may be submitted to
the Calibre/MobileRead community plugin index so Calibre can announce updates.

Calibre keeps toolbar placement and book-context-menu placement as separate user
choices. If the action is visible on the toolbar but not after right-clicking a book,
open **Preferences → Interface → Toolbars & menus**, select **The context menu for books
in the calibre library**, move **Dual Language** from Available actions to
Current actions, and restart Calibre if requested.

### Provider configuration and security

OpenRouter is the default/easiest setup. An OpenRouter key belongs to OpenRouter and is
used with `https://openrouter.ai/api/v1`. An OpenAI key belongs to OpenAI and is used
with `https://api.openai.com/v1`. A custom OpenAI-compatible endpoint requires
credentials from that service. Keys are not interchangeable unless the service
explicitly says so.

The CLI preserves `OPENROUTER_API_KEY`. The plugin also supports, in precedence order,
the selected provider's environment variable (`OPENROUTER_API_KEY` or
`OPENAI_API_KEY`), `EPUB_TRANSLATOR_API_KEY`, and the saved plugin key. The API-key
field is masked, but a saved key is plain text in Calibre's local plugin configuration;
use an environment variable if that is unacceptable. Keys and authorization headers
are excluded from diagnostics.

The settings screen shows the active credential source without displaying its value.
Changing provider presets does not reuse a saved key belonging to another preset. Custom
headers cannot replace authorization or transport headers, and provider redirects are
not followed.

The plugin intentionally does not load the repository's `.env` file. Calibre must be
started with the relevant provider environment variable already set, or the key can be
entered in the masked settings field. If a key is ever pasted into chat, source control,
or a screenshot, revoke it at the provider and create a replacement.

### OpenAI-compatible endpoints

OpenRouter and OpenAI use their documented base URLs. A custom service needs its own
display name, base URL, model, credentials, and optional non-authorization headers.
The transport appends `/chat/completions` exactly once and does not require a `/models`
endpoint. HTTPS certificate verification remains enabled.

### Building and debugging

```bash
python calibre_plugin/build.py
calibre-customize -a dist/epub_dual_language_calibre_plugin.zip
calibre-debug -g
```

The ZIP contains the plugin source and the shared core. It does not bundle platform-
specific wheels or require Conda, pip, `requests`, or an external Python runtime for
normal Calibre users. Its standard-library HTTPS fallback uses TLS verification and
rejects redirects. BeautifulSoup and lxml are supplied by the tested Calibre runtime.

The current development release supports one selected EPUB, dry-run/sample mode, and
separate-edition output. It does not support batch translation, non-EPUB formats, or an
automatic updater. The packaged core, plugin registration, settings/run widgets, and
isolated-library insertion callback have been exercised locally with Calibre 8.16.2;
an end-to-end toolbar click and paid network translation still require a local manual
GUI test.

### Testing the plugin without an API

In the per-book dialog, enable **Test mode (do not contact the API)**. The plugin creates
a valid EPUB containing visibly marked dry-run translations while using the same EPUB
processing, progress, output-validation, and library-insertion paths as a normal run.
No API key is required and no network request is made. Use **Optional sample limit** to
keep the test book small.

Developers can exercise the installed plugin without opening its GUI:

```bash
calibre-debug -r "EPUB Dual Language" -- \
  input.epub output.test.epub Spanish --limit-units 10
```

This command is intentionally dry-run only; it cannot make a provider request.

### CLI installation

Install the package with `python -m pip install .` (or `.[dev]` for tests). The existing
`epub-dual-language` command and its documented options remain supported. Use a `.env`
file or the shell environment for `OPENROUTER_API_KEY`; the CLI does not silently use a
key from a different provider.

### Development and tests

Run the complete local suite and build both artifacts:

```bash
python -m pytest
python -m epub_dual_language.cli --help
python -m build
python calibre_plugin/build.py
```

For Calibre development, use an isolated configuration and library:

```bash
python calibre_plugin/build.py
calibre-customize -a dist/epub_dual_language_calibre_plugin.zip
calibre-debug -g
```

Do not use a real library for plugin experiments. The release workflow checks package
and plugin versions, builds the Calibre ZIP, and attaches the ZIP and checksum to a
GitHub Release. The verification workflow also retains a downloadable ZIP/checksum for
each successful run. The project may later be submitted to the Calibre/MobileRead
community plugin index; there is no custom in-plugin updater.

### Privacy-aware bug reports

Use the issue form's sanitized diagnostics field. Never include API keys, authorization
headers, prompts, source or translated passages, EPUB files, usernames in paths, or
complete Calibre library paths. The plugin offers Copy sanitized diagnostics and Report
bug actions from structured error dialogs.

Run the CLI module directly:

```bash
python -m epub_dual_language.cli "path/to/book.epub" "English" --dry-run --limit-units 10
```
