AI document translation for Word files that keeps formatting
Sending a whole DOCX to a model as plain text loses the formatting. We translate paragraph by paragraph, put the text back in place and estimate the cost.
4 min · · автоматизация · development
An AI tool for translating documents handles plain text easily, but a Word file is harder: if you dump the content as one block of text, styles, tables and numbering are lost. Below is a working Python scheme: take the DOCX, translate paragraphs one at a time, put the translation back into the same paragraph and check the result. We also calculate what a typical document costs to translate.
Why not translate the whole file at once
A DOCX is an archive of XML. Text is split into paragraphs, and paragraphs into runs with different formatting: bold, italic, another font. If you paste the whole text into a chat and ask for a translation, you get text without structure and have to rebuild the layout by hand. If you only change the text inside existing paragraphs, everything else stays: paragraph styles, tables, headers and footers, fields.
There is a practical reason too: a long document in one request becomes a long answer, where the model may skip or shorten a piece. Small fragments are easier to verify.
AI for translating documents: the paragraph scheme
The plan has four steps:
- Walk through the paragraphs of the body, tables and headers or footers.
- For each non-empty paragraph, send the text to the model with a glossary in the system message.
- Write the translation back into the paragraph.
- Save a copy of the file and compare numbers and paragraph counts.
Install:
pip install python-docx openaiCreate a key in the dashboard and put it into the ZUKKO_API_KEY variable. More about the client is in the Python SDK reference.
Walking paragraphs, tables included
Tables store paragraphs inside cells, and merged cells are returned several times, so we skip duplicates:
from docx import Document
def iter_paragraphs(container):
for p in container.paragraphs:
yield p
for table in container.tables:
seen = set()
for row in table.rows:
for cell in row.cells:
if cell._tc in seen:
continue
seen.add(cell._tc)
yield from iter_paragraphs(cell)
def all_paragraphs(doc):
yield from iter_paragraphs(doc)
for section in doc.sections:
for part in (section.header, section.footer):
if not part.is_linked_to_previous:
yield from iter_paragraphs(part)Footnotes, text boxes and fields are not touched in this version: look at them separately after translation.
Writing text without losing style
The simplest solution is to put the whole translation into the paragraph's first run and clear the others. The paragraph style and the first run's formatting are kept, but emphasis inside the paragraph (one bold word in the middle of a sentence) is lost. To keep it, send the model the paragraph with per-run tags and ask it to keep the tags:
import re
def to_tagged(p):
return "".join(f"<r{i}>{r.text}</r{i}>" for i, r in enumerate(p.runs))
def apply_tagged(p, tagged):
parts = {int(i): t for i, t in re.findall(r"<r(\d+)>(.*?)</r\1>", tagged, re.S)}
if set(parts) != set(range(len(p.runs))):
return False
for i, r in enumerate(p.runs):
r.text = parts[i]
return True
def apply_plain(p, text):
if not p.runs:
p.add_run(text)
return
p.runs[0].text = text
for r in p.runs[1:]:
r.text = ""The logic: for single-run paragraphs we translate plain text; for multi-run paragraphs we try the tagged version, and if the model breaks the tags we fall back to apply_plain. This way formatting is lost only in rare cases, and you can track them.
The model request and the glossary
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"])
GLOSSARY = {
"Lessee": "Арендатор",
"Lessor": "Арендодатель",
"Force Majeure": "Форс-мажор",
}
SYSTEM = (
"You are a professional translator from English to Russian. "The glossary exists so that "Lessee" is not rendered as one word in one place and another in a different place. For a large document keep the glossary short: it is sent with every paragraph and counts toward the cost.
The main loop
from collections import Counter
def numbers(s):
return Counter(re.findall(r"\d+(?:[.,]\d+)?", s))
doc = Document("contract_en.docx")
problems = []
for p in all_paragraphs(doc):
src = p.text
if not src.strip():
continue
if len(p.runs) > 1:
out = translate(to_tagged(p))Comparing numbers is a rough but useful check: a mismatch points to paragraphs where the model may have dropped an amount or a date. Formats like "1,000" and "1 000" will give false alarms, which is fine.
How to check the result
The check does not replace a specialist's review, but it removes part of the errors before it:
- compare the paragraph count in the source and the translation;
- go through the
problemslist from the code above; - search the text for leftover English glossary terms;
- open the file in Word and check the table of contents, numbering and fields: they update manually.
Legal, medical and technical documents should be read by a person who knows the field. The model translates text, but responsibility for the meaning stays with you.
How much document translation costs
Suppose a 20-page document: 15,000 tokens of source text and about 30,000 tokens of Russian translation (Russian text usually takes more tokens than English). The glossary and instruction take about 300 tokens, sent with each of 400 paragraphs: 120,000 tokens. In total about 135,000 input tokens and 30,000 output, ignoring cache. Prices as of 18 October 2026, formula: input × input price + output × output price.
| Model | Calculation | Total |
|---|---|---|
deepseek-v4.1-flash | 0.135 × 0.013 + 0.03 × 0.038 | about $0.003 |
gpt-6.1-sol | 0.135 × 0.27 + 0.03 × 1.32 | about $0.076 |
claude-sonnet-5-5 | 0.135 × 0.4 + 0.03 × 2 | about $0.114 |
The figures depend on document length and the glossary. For larger volumes, see the article on translating text through the API.
Free options
The query "free AI for translating documents" is popular, but the terms of free services (file size, pages per day, what happens to an uploaded document) change, and we do not know them. If the document is confidential, read the service's terms before uploading. For a one-off test a trial balance works: $0.50 for subscribing to @zukkopro (once per Telegram account) goes a long way on a cheap model.
FAQ
Does this work with the old .doc format?
No, python-docx reads only DOCX. Re-save the file as DOCX from Word or another editor first.
How do I translate a document from English to Russian and back?
Change the line in SYSTEM: set the direction and the glossary the other way. The rest of the code stays the same.
What about PDF?
A PDF does not store paragraphs as structure, so the scheme is different: recognize the text first, then translate. One approach is described in the article on processing PDFs through vision.
Which model should I take?
Start with a cheap one and check 10 to 20 paragraphs of your own document. If terminology drifts, move to a stronger model from the list in the models reference and compare on the same paragraphs.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Translating subtitles with a neural network: SRT without broken timecodesThe main risk when translating SRT with a neural network is a broken file structure. A scheme where timecodes never reach the model, and a Python script with validation.
- Editing texts with a neural network: proofreading, style and cuttingDifferent kinds of editing need different prompts. Wording for proofreading, style and cutting, how to get edits with explanations and how not to lose the author's voice.
- A neural network for writing scripts: Bash, PowerShell and PythonA model writes scripts for routine fast, but running them blindly is risky. How to state the task, demand a safe dry-run mode and check the result before launch.
- Dify in Docker: installation, first-run setup and connecting modelsInstall Dify with Docker Compose, create the admin account and connect Claude, GPT and DeepSeek models on one key. Plus what to do when containers will not start.