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:

  1. Walk through the paragraphs of the body, tables and headers or footers.
  2. For each non-empty paragraph, send the text to the model with a glossary in the system message.
  3. Write the translation back into the paragraph.
  4. Save a copy of the file and compare numbers and paragraph counts.

Install:

bash
pip install python-docx openai

Create 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:

bash
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:

bash
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

bash
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

bash
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 problems list 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.

ModelCalculationTotal
deepseek-v4.1-flash0.135 × 0.013 + 0.03 × 0.038about $0.003
gpt-6.1-sol0.135 × 0.27 + 0.03 × 1.32about $0.076
claude-sonnet-5-50.135 × 0.4 + 0.03 × 2about $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.

Get API keyDocumentation

api.zukko.pro · fetch_*

AI document translation for Word: python-docx · Zukko