Translating a 300-page book in a chat window fails in predictable ways. The model summarizes when you asked for a translation. Terminology drifts from chapter to chapter, so the same term appears under three different Persian words. Running headers and page numbers get translated as if they were content. And one crashed session throws away an hour of work.
I wanted to find out what it takes to do this properly, so I built Kaman AI Translator — a Windows desktop app that translates PDF and EPUB books page by page into academic Persian, and back into English, with the boring failure modes handled by local code you can read.
The repository is two Python files and a test suite. The interesting part is not the interface; it is the pipeline underneath it.
The constraint that shaped everything
The "AI" is not a hosted service inside the app. It is a thin, replaceable engine layer — the official Google Gemini SDK, or any OpenAI-compatible endpoint (Ollama, LM Studio, llama.cpp, or a cloud aggregator).
That matters because the translation strategy — chunking, context rolling, structure templates, quality control — is local, deterministic code. The model is a tool the strategy calls; it is never the thing making the decisions. That is why the entire pipeline can be tested headlessly with a fake engine and no API key.
Chunking that never cuts a sentence in half
Long input is the first thing that breaks. The default character budget is 6,000 per request, and the splitter works in three tiers: paragraph boundaries first, then sentence boundaries, then words — never mid-sentence.
The units are then repacked under the budget, so a request is not wasted on a single oversized paragraph, and no chunk arrives full of fragments.
A rolling context tail rides along with each chunk: the last 700 characters of the previous translation are appended to the prompt, so terminology and prose flow stay consistent across a chapter instead of restarting every page. The setting can be set to zero to turn it off entirely, but the default is on, because it is the single cheapest quality improvement in the whole app.
The per-book structure template
The most interesting piece of local logic answers a specific problem: how do you stop the same recurring label from being translated five different ways across a book?
The app detects, per book, locally and for free, two things:
- Headings — a chapter title should read as a chapter title, not as body text.
- Recurring labels — things like "Did you know?" or "Activities" that appear dozens of times and must render identically every single time.
Both are injected into every prompt for that book, so the label is always translated the same way. They are editable by hand in a template editor, and an optional AI pass can read the table of contents and extract a deeper structure for one extra API request.
The detection is cheap: lines that repeat on at least a quarter of the sampled pages near the top or bottom edge of a page are treated as running headers and dropped before the model ever sees them. Hyphen-broken words are rejoined — exam- plus ple becomes example — and list numbers and mid-text formulas are protected.
Completeness-enforcing prompts, and a second pass that checks the work
The prompts are explicit that a translation must not omit, must not summarize, and must preserve paragraph structure, numbers, equations and citation markers like [12], with one consistent term per concept.
Then there is an optional QC loop: a second pass compares the translation against its own source text, flags meaning drift and omissions, and returns a corrected version. It is genuinely a second call, and the interface says so — the cost of the QC pass is roughly double.
There is also a JSON-safety layer. Models sometimes reply with raw JSON, or with a JSON object wrapped in prose. Those replies are unwrapped, repaired, or accepted as plain text, so the user never sees JSON garbage in the output box.
Scanned pages are not skipped
A scanned book has no text layer. Naive translation treats an empty page as an empty page, and a 300-page scan becomes a 300-page gap.
Here, completely empty extracted text triggers a different path: the page is rendered as a PNG at a 2× zoom (roughly 144 DPI) and sent to a vision-capable model that reads it and translates it in one step.
Two honest limits are worth stating. The provider must accept images — a text-only local model cannot do this, which is one reason the scanned path practically points at Gemini. And because the page has no extracted source text, the QC loop is skipped for scanned pages: there is nothing independent to verify the translation against. Those pages deserve a human "Reviewed" mark more than text pages do.
Nothing is lost, ever
The property I cared about most is that finished work is never lost — not to a crash, not to a quota stop, not to a restart.
Every book has a JSON state file, named by a hash of its absolute path, written atomically after every single page: the data is written to a temporary file and then os.replaced into position, so the file is never observed half-written even if the process is killed mid-save. A threading.Lock guards the operation.
When you reopen a book with saved pages, the app asks an explicit question — continue from the first unfinished page, or start over from the beginning — and continue is the default. The structure template survives even a restart, because the book has not changed and re-detecting it would be waste.
The cost of a lost run is at most the page that was in flight.
The key is encrypted, and the app talks to nothing else
The API key is entered once in the settings dialog, masked in the UI, and stored encrypted with Windows DPAPI — the Windows Data Protection API, bound to your user account and your machine. A different user on the same machine, or the same user on a different machine, cannot decrypt it.
There is a fallback for platforms without DPAPI, and the app does not hide it: the key falls back to clearly-labelled base64 storage, and the reason is written to encrypt_fallback.log — a timestamp and the error message, never any part of the secret. Base64 is encoding, not encryption, and the project says so rather than pretending otherwise.
The complete network surface is one row: the provider you configured. There is no telemetry, no update checker, no crash reporting, no third-party service, and no analytics. The settings dialog shows the exact data folder path and has a one-click button to open it.
If you want nothing to leave the machine at all, point the OpenAI-compatible provider at Ollama on localhost — that is what the default base URL (http://localhost:11434/v1) is for.
The interface is Persian, and that means RTL all the way down
This is not an English layout with Persian strings. The whole application is right-to-left, with Persian numerals throughout (۰۱۲۳۴۵۶۷۸۹).
That has real consequences, and they are asserted by the test suite rather than left to chance:
- "Next page" points left and "previous" points right, because that is the direction of travel in RTL.
- The source panel is added first in the layout, so it lands on the right — where a Persian reader expects the original text.
- The progress percentage is right-aligned, and the progress bar's internal text is hidden because Qt draws it on the left.
- Spin-box values are right-aligned through a dedicated filter, re-applied on theme changes, because Qt re-lays-out spin boxes and loses the alignment otherwise.
- The copy button is blue when the current page has a translation and grey when it does not, so its state is readable without a tooltip.
During a long run the app follows the page being translated, live — but page navigation stays fully unlocked. The moment you browse somewhere yourself, the app stops moving the view for you. The batch keeps running; only the auto-follow steps aside.
Three exports
When the book is done, the save menu offers three formats:
- One page as TXT, with a header naming the book and the page number, written as
utf-8-sigso Persian renders correctly in Windows Notepad instead of as mojibake. - The whole book as Markdown, one
##section per page in numeric order — version-controllable and diffable. - The whole book as a standalone RTL HTML edition with a page image beside every translation, inline CSS, and print rules that stop a page from splitting across a printed sheet. Open it in a browser and Print → Save as PDF gives a finished Persian PDF with the original page images.
The HTML route is a deliberate choice: producing a correct right-to-left PDF requires embedding a Persian font and handling bidi layout properly, which a browser already does well. Direct PDF output with embedded RTL font rendering is on the project's roadmap, but the HTML export is good today, and it uses the fonts the user already has.
What I would build next
The honest next step is not a better model. It is better data: a real evaluation set of books with the translations a good human translator would produce, so the quality of the pipeline can be measured rather than argued about. A glossary export and import — one term list shared across several books — is the feature I would add first, because consistency across a series of books is the thing per-book templates cannot solve.
The numbers, and where they come from
| Fact | Value |
|---|---|
| Application version | 3.0.0 |
| Core | kaman_core.py, ~1,400 lines, Qt-free |
| UI | translator_app_v3.py, ~1,800 lines |
| Test suite | 302 checks, all passing, fully offline |
| Runtime dependencies | 4 — PyQt6, PyMuPDF, Pillow, google-genai |
| Default chunk budget | 6,000 characters |
| Default context tail | 700 characters |
| Scanned-page render | 2× matrix (≈144 DPI) |
| Key protection | Windows DPAPI, with a logged base64 fallback |
| Releases | v3.0.0 (Windows executable + source zip), v2.0.0 |
The test suite runs with no network, no API key and no provider account: fake engines stand in, and the user's real profile is never touched because APPDATA is redirected to a temporary folder before the app modules are imported. A run prints 302 passing checks and zero failures — that is the measured number, not a rounded-up one.
The full technical documentation is published as a wiki: the architecture, the translation pipeline, the engines, the structure templates, the scanned-page path, the job store, the exports, the interface, the settings, the security model, the test suite, and the release process.
Related: the same site documents a Persian counseling widget and a messenger bot built on the same ideas — a local, auditable engine with a hosted model only where it earns its place.