Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

Joven — a Cormac McCarthy ebook Spanish annotator

CI Python 3.11+ License: MIT

                                      :@@#                               =@@+.
                                     +@@@@                              #@@@@.
                                    .@@#@@                            .*@@=@@.
                                    #@@@@%                            #@@=@@#
                                    @@:@@*                           :@@+:@@
                                    @@=@@@                           %@@-:@@
                                    @@%@@@*.                        .@@=*+@@
                                    @@@+@@@=.                       *@%+#%@@
                                    *@@@@%@@%=.                    .@@*:-@@@
                                     @@@====#@%=.                  .@@@@*@@@.
                                     :#@@*::--#@@@#.%@@@@@:        #@@=+**@@.
                                       =@@#-*+@@@@@@@@@@@@@@%      @@@%*#*@@.
                                    -@@@@@@@@@@@-%@@@%..*@@@@@@-+@@@@@@%*+@@
                               +@@@@@@@@@@@@@@=:: ....:::::%@@@-@#*=:+#**@@@
                              =@@@@@@@*:.:::.:.::.:....... :@@@%@:**=:-%@@@+
                            .%@@@-  :..    ..:::  ....::::-=#@@@=:+=**#@%=
                      .. %@@@@@@+   :.:...=@@@@@@:  .:--:**==%@@+%#%*#*-
                  .@@@@@@@@@@@+=-   :::  :@@@@@@@+. :.:--#-*:@@@#%@+
             +@@@@@@@@@@@@%. :+-*:.      *@@=@@@@@.:=@@%:-*=%@@.
            .#@@@@@@+@@+=--==:==-::  .:=+@@+%@%@@@@@@@=@*%+ @@%.
            +@@@-===@-@==-----:::::  .===*@@@@@@@+@%#=*%@%@#@@
         %@@@@@%=-:=%@%:---==.-=-:. ..-++*%@@@@@:=*@@ @@@@@+@@
   ...%@@@@@=@#=..:-:=====-: :..::   .:-==+:+@@@*@@*+%@@%@@-@@
   #@@@@%*@@*@*=:-:-**=-: ...:-==:      .:=*:*+=+=:%@@@@=@@@@%
.#@@@@@:@*=@@===:.=+-. :::.:::.          *@-+*:.. =@@@@= %@@@=
*@@@=%==@@@#+=@@@@@@@+.: :              *@*%-:..:.@@@
@@@=*.:+:-@@@@@@@@%@@@%.::            .=@+@@:. :--#@@
@@@=@@%*@%@*=*@-@@##@@@%=:::: .:==-:::=@*+%-. .:=:.@@
%@@@@%-%%%@+@@@@@=*=-@@*=#=.:=+-:::.:-**+%+:  .:=::@@
  %@@@@@@=@*@@@@@@@@@@@*%=#@%=:-::-:-::*%*:..::::#@@@
   .=@@@@@@+@@@%-@@@@*.:%#*.@=*-:=..:===- ::::-=*@@@@
        #@@@@@@@@@@%   :=+@@% :=+-  .:-: : .::-+@@@@+
             .#@@@@@@@@@.              ..:=@@@@@@@@*
                . %@@@@@@@@@@@%%##@@@@@@@@@@@@@@@%-
                     .#@@@@@@@@@@@@@@@@@@@@@#+=:
                           -%@@@@@@@+:.

The boy sat down to read the book. He read of horses and of the country to the south and of men who spoke in a tongue he did not have. Escúchame, joven, said the old man, and the boy did not know what it was he was meant to hear. So he set down the book and took up the phone and fed the words into Google Translate one at a time like a man counting stones across a river, and when he looked up again the twilight had gone out of the valley and the page had gone cold and the old man was still standing there in the dark holding his counsel like a man holding a lamp for nobody.

So the boy wrote a python service. It went through the book and marked out every passage that was not English and put the question to a small model that ran on his own machine and asked nothing of anyone and told no one what it had seen. Where the Spanish had been it set a single asterisk and no more than that, and under the asterisk it laid the English down like a coin under a tongue, and the boy could take it or leave it as he pleased. He read the book through and he never once opened Google Translate. The old man went on speaking in his own language as he had always done and as he would go on doing, and the boy understood him, and the sun went down bloodred over the mesa and he read on by the pale light of the device until the battery gave out.

Joven is a command-line tool that finds the untranslated Spanish in an English novel and inserts tappable translation footnotes. You give it an EPUB you own; it gives you back an annotated copy for your e-reader, with the prose byte-for-byte unchanged. It was written for Cormac McCarthy's The Crossing and works well on his other westerns.

Everything runs on your machine against a local model: no API key, no per-book cost, nothing uploaded.


Why this exists

The Crossing is an English novel with a great deal of Spanish in it, and the book never translates or contextualizes it. 726 Spanish sentences and phrases, mixed into English paragraphs. Reaching for a dictionary breaks the trance the prose spent forty pages building; skimming past leaves a hole in the page.

Your e-reader's own Dictionary and Translate do not rescue you, because McCarthy uses no quotation marks — speech and narration run together in one stream, and the Spanish arrives in four distinct shapes:

A  Vaya con Dios.                          a whole paragraph, no English at all
B  Cuántos años tienes? the old man said.  Spanish speech, English dialogue tag
C  The matríz will not help you, he said.  English prose, Spanish loanword
D  Escúchame, joven, the old man wheezed.  Spanish opener, then English narration

C and D are why per-paragraph language detection fails in both directions: C is a false positive waiting to happen, D is a guaranteed miss. Solving that is most of what this tool is — see DESIGN.md §2 for the measurements.

What you get

A copy of your EPUB in which every Spanish passage carries a small *. Tap it and the translation appears in the reader's own footnote popup; ignore it and the page reads exactly as the author set it down.

A page of The Crossing on a Kobo, with a small asterisk after each Spanish line The same page with Kobo's Footnote preview open, showing the English translation
The page as McCarthy set it down, plus one asterisk per Spanish passage. The same page one tap later — the Kobo's own Footnote preview.
  • Unintrusive by construction. One asterisk. No inline brackets, no interlinear clutter, no colour. The footnote is opt-in, the way McCarthy's silence is opt-in.
  • The prose is untouched. Strip the inserted nodes from the output and what remains is byte-identical to the original — enforced by a test, verified on the real book.
  • It stays on your machine. A local qwen3:8b does the translating — 73 minutes for the whole novel, $0, and the book never leaves the laptop.
  • Precision over recall. A spurious footnote on Go on. is worse than a missed one, so ambiguous cases escalate to the model rather than guess.

How it works

Five commands, two of which do the real work. Detection is two-tier: a cheap statistical pass judges every sentence, and only the fraction it cannot call is put to the local LLM.

flowchart TD
    START(["your book.epub"])

    subgraph DETECT["joven detect · ~73 min · $0"]
        direction TB
        EXTRACT["extract text units<br>addressable by file, element path, offset"]
        SEG["segment into sentences<br>character offsets preserved"]
        T1{"TIER 1 — lingua triage<br>statistical, instant, free"}
        T2{"TIER 2 — local LLM<br>qwen3:8b via Ollama, with surrounding context"}
        MERGE["merge contiguous Spanish<br>one footnote per paragraph"]
        EXTRACT --> SEG --> T1
        T1 -- "abstains — 21% of segments" --> T2
        T1 -- "confident Spanish" --> MERGE
        T2 -- "yes, and here is the span" --> MERGE
    end

    SKIP["left alone<br>but still recorded in trace.jsonl"]
    T1 -- "confident English" --> SKIP
    T2 -- "not Spanish" --> SKIP

    SIDECAR[("annotations.json<br>source of truth, human-editable")]
    REVIEW["joven review<br>approve / edit / reject<br>decisions survive re-detection"]
    MERGE --> SIDECAR
    SIDECAR <--> REVIEW

    subgraph RENDER["joven render · idempotent"]
        direction TB
        ANN["insert markers and note documents<br>EPUB 2 → 3 package upgrade"]
        EPUB3(["book.annotated.epub"])
        KEP["kepubify"]
        ANN --> EPUB3 --> KEP
    end

    START --> EXTRACT
    START -. "the original, never edited in place" .-> ANN
    SIDECAR --> ANN
    KEP --> OUT(["book.annotated.kepub.epub<br>copy this to the Kobo"])
    EPUB3 -. "validated by" .-> VERIFY["joven verify<br>12 checks · epubcheck · text-preservation invariant"]

    classDef artifact fill:#fff4e0,stroke:#b8860b,stroke-width:2px,color:#111
    classDef endpoint fill:#e6f4ea,stroke:#2e7d4f,stroke-width:2px,color:#111
    classDef muted fill:#f2f2f2,stroke:#999,color:#444
    class SIDECAR artifact
    class START,OUT endpoint
    class SKIP muted

The EPUB is never edited in place. annotations.json is the durable artifact, and rendering is a pure function of (original EPUB + sidecar). Corrections mean editing the sidecar and re-rendering — never re-translating — and re-running detection merges into your edits instead of clobbering them.

Inside one Tier-2 call

Tier 2 is not a translation step with a question bolted on. It answers three things at once — is this Spanish, which part of it is Spanish, and what does it mean — which is why it is an instruct model and not a translation engine. An NMT engine can only answer the third, and would cheerfully "translate" Yes mam.

flowchart TB
    subgraph REQUEST["one /api/chat request to Ollama"]
        direction TB
        SYS["SYSTEM<br>the task, the JSON contract, and a warning<br>that McCarthy's dialect English is English"]
        FS["6 FEW-SHOT TURNS<br>one per mixing pattern, plus two dialect traps<br>added after a traced run exposed them"]
        USR["USER<br>up to 400 characters of preceding prose, fenced<br>and marked do-not-translate, then the paragraph"]
        SYS --- FS --- USR
    end

    USR --> MODEL["qwen3:8b · temperature 0 · thinking off<br>reply constrained to a JSON schema"]
    MODEL --> OUT["is_spanish · spanish_text · translation"]

    OUT --> G1{"is the translation<br>just the source tidied up?"}
    G1 -- yes --> VETO["vetoed — a footnote<br>that teaches nothing"]
    G1 -- no --> G2{"one Spanish word<br>inside English prose?"}
    G2 -- yes --> LOAN["suppressed —<br>embedded loanword"]
    G2 -- no --> KEEP["annotation, span narrowed<br>to spanish_text"]

    VETO --> TRACE[("trace.jsonl<br>every outcome, with the raw reply")]
    LOAN --> TRACE
    KEEP --> TRACE

    classDef keep fill:#e6f4ea,stroke:#2e7d4f,stroke-width:2px,color:#111
    classDef drop fill:#f2f2f2,stroke:#999,color:#444
    classDef store fill:#fff4e0,stroke:#b8860b,stroke-width:2px,color:#111
    class KEEP keep
    class VETO,LOAN drop
    class TRACE store

There are two prompts, not one, and which runs depends on what Tier 1 already decided. The split was forced by a bug in each direction:

Tier 1 said Prompt Because
abstained (the band) adjudicate — is this Spanish, and if so which part? The genuine open question
confident Spanish translate only — this is Spanish; do not second-guess it A combined prompt let the model veto Tier 1's correct calls. It labels Dieciseis. English.

Skipping the model entirely for Tier-1 accepts was the first implementation, and it shipped annotations with no translation at all — blank popups on the device.

The prompts themselves, the context-bleed problem, a real call from the trace, and the three gates in detail: docs/anatomy-of-a-call.md.

The components

src/joven/ is about 4,500 lines. The pieces map onto the pipeline above:

Module Job
epub/ Byte-level zip surgery — copy every archive entry verbatim, reserialize only the XHTML that actually changed
detect/ segment.py splits paragraphs into sentences; triage.py is Tier 1 (lingua + tag-stripping); pipeline.py runs both tiers and merges contiguous Spanish into one footnote per paragraph
translate.py Tier 2 — the Ollama client, the Translator protocol, and the similarity veto that suppresses no-op "translations"
dialogue.py McCarthy's , he said. vocabulary, shared by every gate that must not measure it
model.py The annotations.json sidecar — content-hash IDs, statuses, merge semantics
render/ Marker insertion, the EPUB 2→3 package upgrade, one-note-per-file footnote documents
kepub.py The kepubify hand-off that produces the file the Kobo wants
review.py, suspicion.py The localhost review UI, and the heuristic that sorts likely-wrong translations to the top of it
verify.py The 12-check integrity gate, including the text-preservation invariant
trace.py One JSONL record per segment, annotated or not, so any missing footnote can be explained afterwards

Requirements

Platform macOS on Apple Silicon — where this was developed and device-verified. CI also runs the suite on Linux. Windows is untested.
Memory 16 GB, which is what sets the model ceiling at 7–14B parameters
Disk ~6 GB for the model, plus room for the outputs
Python 3.11+ (developed on 3.13)
Homebrew for the three external binaries below
Java epubcheck is a JAR and needs a JVM; macOS ships one at /usr/bin/java
Reader A Kobo (sideloaded over USB) is the verified target — tested on firmware 4.45.23697. Apple Books renders the footnotes as popups too.

Three external tools do work Python should not:

Tool Why
ollama Runs qwen3:8b (5.2 GB) locally. Chosen on a benchmark, not a hunch — docs/model-selection.md.
kepubify Converts the finished EPUB into Kobo's own KEPUB flavour, which renders better on device
epubcheck The reference EPUB validator — the external gate on our output

Bring your own EPUB. Joven reads a file you already have; it does not fetch, share, or unlock anything, and it refuses DRM-protected files outright. Books that merely obfuscate an embedded font are read normally — that is not DRM, and the scrambled font is carried through untouched.

Install

The three external binaries first:

brew install epubcheck kepubify ollama

Then the tool, into its own isolated environment:

uv tool install joven-ebook-annotator

pipx install joven-ebook-annotator does the same thing if you have pipx instead. Both pull prebuilt wheels — worth knowing that lingua carries its language models inside the wheel, so this step moves about 170 MB.

Then the model, once:

ollama serve &            # leave running
ollama pull qwen3:8b      # 5.2 GB

There is no Homebrew formula. One dependency (lingua-language-detector) publishes no source distribution at all — only per-platform wheels — which a Homebrew Python formula cannot consume without hand-pinned wheel URLs per architecture and per CPython minor version. Two commands that work beat one command that breaks on the next python@ bump.

To work on the code rather than use it, see Development.

Use

One book, five commands, in order:

joven inspect book.epub                                    # structure, DRM, word counts
joven detect  book.epub -o annotations.json --trace trace.jsonl
joven review  annotations.json --epub book.epub            # triage, suspect passages first
joven render  book.epub annotations.json -o out/           # EPUB 3 + KEPUB for the Kobo
joven verify  out/book.annotated.epub --original book.epub

inspect reports what you are holding — EPUB version, DRM, spine, word counts. Run it first; it fails fast on a file the rest of the pipeline cannot use.

detect is the slow step: about 73 minutes for a 150,000-word novel, single threaded, $0. It writes annotations.json (the sidecar you will edit) and, with --trace, a JSONL record of every segment it looked at. Re-running it is safe — your review decisions are sticky.

The trace is also the run's recovery log: it is flushed a record at a time, so an interrupted run loses nothing that the model already answered.

joven detect book.epub -o annotations.json --trace trace.jsonl --resume trace.jsonl

--resume reuses every model answer the trace already holds and pays only for the segments the previous run never reached. Tier 1 and every suppression gate still run over the whole book, so a resumed run picks up threshold changes rather than replaying stale conclusions; recorded errors are retried rather than inherited.

review opens a local page listing every annotation with the surrounding prose and the Spanish highlighted. Approve, edit, or reject (a/r/e); each decision writes straight to the sidecar the moment you make it, so quitting mid-review loses nothing. Suspect annotations sort first, each badged with the reason — untranslated: historic, garbled source: Conoc16 — and everything else follows in book order.

render applies the sidecar to a fresh copy of the original and emits two files: a spec-clean .epub (the verifiable intermediate, which is what epubcheck validates) and a .kepub.epub (what you copy to the Kobo). It is idempotent and never touches your source file.

verify runs the 12-check integrity gate — the text-preservation invariant, epubcheck, noteref→footnote resolution, and the rest.

Then copy the .kepub.epub to the Kobo's root over USB, eject, and the device imports it.

Missed a passage while reading?

joven add annotations.json --epub book.epub \
  --find "Bueno pues" --translation "Well then"

Added entries are marked edited, so re-detection will never overwrite them.

For tuning, tracing, and the debug flags, see docs/troubleshooting.md.

Results

Working end to end on a real book: lossless round-trip, two-tier detection with a full decision trace, EPUB 2→3 upgrade, footnote rendering device-verified on a Kobo, KEPUB output, a review pass, and a finished book on the device.

Last full run — Knopf's 1994 edition of The Crossing, 151,865 words:

segments considered 12,302
escalated to the LLM 2,556 (21%)
footnotes produced 726
wall clock / cost 73 minutes / $0
integrity checks 12 of 12 passing

Known limitations, and what the project has and has not proven, are in CHANGELOG.md.

Development

git clone https://github.com/vyanhursky/joven && cd joven
python3.13 -m venv .venv
./.venv/bin/pip install -e '.[dev]'
./.venv/bin/pytest                       # 369 tests, synthetic fixtures only
./.venv/bin/ruff check src tests tools

# opt in to the real-book tests (the book is never committed)
JOVEN_TEST_EPUB=/path/to/book.epub ./.venv/bin/pytest

Tests never hit the network — a StubTranslator stands in for the LLM everywhere. Two guard scripts run in CI, each of which has caught a real bug that survived a careful manual read of the same files minutes earlier:

python tools/check_docs.py               # every documented command and flag exists
python tools/check_no_book_content.py    # no book text in tracked files

Model benchmarks are separate tools, not tests, because they need a running Ollama:

python tools/bench_models.py             # the LLM in isolation
python tools/bench_pipeline.py           # the two-tier system that actually ships

Further reading

DESIGN.md Why the architecture is shaped this way — the measurements behind every decision, what the device tests overturned, and the work deliberately left undone
docs/model-selection.md The local-model benchmark: why qwen3:8b
docs/anatomy-of-a-call.md What the local model is asked, what it may answer, and the gates that check it
docs/troubleshooting.md Tracing a missing footnote, improving translation quality, debug flags, and Kobo quirks
docs/releasing.md Cutting a release: the one-time PyPI trusted-publisher setup, and what CI does with a tag
CHANGELOG.md Release notes and known limitations

Licence

The code is MIT — see LICENSE. Use it, fork it, ship it.

The books are not. The annotated output is a derivative of a copyrighted work: read it, don't distribute it.

The repository deliberately tracks no book content. The hazard is not the EPUB but what the pipeline derives from it — a full decision trace holds every segment's source text verbatim, which for a 150,000-word novel is the entire book. .gitignore excludes those by pattern, and tools/check_no_book_content.py verifies it by content, diffing every tracked file against the book itself:

python tools/check_no_book_content.py path/to/book.epub

Docs and tests quote short passages to illustrate the detection problem, and the two screenshots above are photographs of the author's own copy on his own device.

Metadata

Release files for joven-ebook-annotator 1.0.0b2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for joven-ebook-annotator 1.0.0b2
File Size Uploaded
joven_ebook_annotator-1.0.0b2.tar.gz 80.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for joven-ebook-annotator 1.0.0b2
File Interpreter ABI Platform
joven_ebook_annotator-1.0.0b2-py3-none-any.whl Python 3 none any Details

Total release size: 161.1 kB

Release files / joven_ebook_annotator-1.0.0b2.tar.gz

Download URL joven_ebook_annotator-1.0.0b2.tar.gz
Size 80.1 kB
Tags Source
SHA-256 checksum
How to use checksums
fc39f3fd7c490d8e7a7a939d7ab330dd617ef94eb73e3d2810089f61b96bd8b1
BLAKE2b-256 checksum
How to use checksums
8fb2276cbb718cad1ba3ca60a2e4df39ff37c7cab96bdc56f2346c84c3885d39
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.

Transparency log

Release files / joven_ebook_annotator-1.0.0b2-py3-none-any.whl

Download URL joven_ebook_annotator-1.0.0b2-py3-none-any.whl
Size 81.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5647a594293f79a01aab9fb9d97420c0c015807dc6211b38992fa97ad6423220
BLAKE2b-256 checksum
How to use checksums
1f54562f3dc4385ace5916483e54d19f196e0ab7a708e8bc69065a61959b5072
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page