Here’s a ready-to-run Python GUI app (Tkinter) that translates Word documents (.docx) using the DeepL API (Document Translation). It uploads your file to DeepL, polls the job status, and downloads the translated document while preserving formatting.
It also supports DeepL Free (api-free.deepl.com) and DeepL Pro (api.deepl.com).
Just paste your API key, pick a target language, choose a .docx
, and click Translate.
YourFile.translated.<LANG>.docx
in chosen output folderpip install requests python-dotenv
Optional: Create a
.env
file withDEEPL_API_KEY=your_key_here
The app will auto-load it.
:fx
are usually Free keys.BG, CS, DA, DE, EL, EN, EN-GB, EN-US, ES, ET, FI, FR, HU, ID, IT, JA, KO, LT, LV, NB, NL, PL, PT, PT-BR, PT-PT, RO, RU, SK, SL, SV, TR, UK, ZH
deepl_docx_translator.py
)#!/usr/bin/env python3
"""
DeepL Document Translator GUI (for .docx)
-----------------------------------------
- Translates Word documents using DeepL's document translation API
- Preserves formatting (DeepL handles conversion)
- Works with DeepL Free and Pro
Setup:
pip install requests python-dotenv
(optional) .env file with: DEEPL_API_KEY=your_key
Run:
python deepl_docx_translator.py
"""
import os
import sys
import time
import threading
import traceback
import requests
from tkinter import Tk, StringVar, BooleanVar, filedialog, messagebox
from tkinter import ttk
from dotenv import load_dotenv
from pathlib import Path
# ---------- Config ----------
APP_TITLE = "DeepL Document Translator (DOCX)"
DEFAULT_POLL_SECONDS = 2
TIMEOUT_SECONDS = 60 * 10 # 10 minutes safety (adjust as you like)
# DeepL endpoints
DEEPL_PRO = "https://api.deepl.com"
DEEPL_FREE = "https://api-free.deepl.com"
# Known target languages & nice labels (DeepL supports others; you can fetch dynamically if desired)
TARGET_LANGS = [
("Auto-detect source → English (EN)", "EN"),
("English (US) (EN-US)", "EN-US"),
("English (UK) (EN-GB)", "EN-GB"),
("German (DE)", "DE"),
("French (FR)", "FR"),
("Spanish (ES)", "ES"),
("Italian (IT)", "IT"),
("Dutch (NL)", "NL"),
("Portuguese (PT)", "PT"),
("Portuguese (Brazil) (PT-BR)", "PT-BR"),
("Portuguese (Portugal) (PT-PT)", "PT-PT"),
("Polish (PL)", "PL"),
("Czech (CS)", "CS"),
("Danish (DA)", "DA"),
("Greek (EL)", "EL"),
("Estonian (ET)", "ET"),
("Finnish (FI)", "FI"),
("Hungarian (HU)", "HU"),
("Indonesian (ID)", "ID"),
("Japanese (JA)", "JA"),
("Korean (KO)", "KO"),
("Lithuanian (LT)", "LT"),
("Latvian (LV)", "LV"),
("Norwegian Bokmål (NB)", "NB"),
("Romanian (RO)", "RO"),
("Russian (RU)", "RU"),
("Slovak (SK)", "SK"),
("Slovenian (SL)", "SL"),
("Swedish (SV)", "SV"),
("Turkish (TR)", "TR"),
("Ukrainian (UK)", "UK"),
("Bulgarian (BG)", "BG"),
("Chinese (ZH)", "ZH"),
]
# Optional: Source language options (leave empty for auto-detect)
SOURCE_LANGS = [
("Auto-detect (recommended)", ""),
("English (EN)", "EN"),
("German (DE)", "DE"),
("French (FR)", "FR"),
("Spanish (ES)", "ES"),
("Italian (IT)", "IT"),
("Dutch (NL)", "NL"),
("Polish (PL)", "PL"),
("Portuguese (PT)", "PT"),
("Portuguese (Brazil) (PT-BR)", "PT-BR"),
("Portuguese (Portugal) (PT-PT)", "PT-PT"),
("Czech (CS)", "CS"),
("Danish (DA)", "DA"),
("Greek (EL)", "EL"),
("Estonian (ET)", "ET"),
("Finnish (FI)", "FI"),
("Hungarian (HU)", "HU"),
("Indonesian (ID)", "ID"),
("Japanese (JA)", "JA"),
("Korean (KO)", "KO"),
("Lithuanian (LT)", "LT"),
("Latvian (LV)", "LV"),
("Norwegian Bokmål (NB)", "NB"),
("Romanian (RO)", "RO"),
("Russian (RU)", "RU"),
("Slovak (SK)", "SK"),
("Slovenian (SL)", "SL"),
("Swedish (SV)", "SV"),
("Turkish (TR)", "TR"),
("Ukrainian (UK)", "UK"),
("Bulgarian (BG)", "BG"),
("Chinese (ZH)", "ZH"),
]
# ---------- Helpers ----------
def infer_base_url(api_key: str, force_free: bool) -> str:
"""
Decide which DeepL host to use.
- If user checked "Use Free API" → api-free
- Else if key ends with ':fx' (common for free keys) → api-free
- Otherwise → api (Pro)
"""
if force_free:
return DEEPL_FREE
if api_key and api_key.endswith(":fx"):
return DEEPL_FREE
return DEEPL_PRO
def translate_doc(api_key: str, base_url: str, src_file: Path, out_dir: Path, target_lang: str, source_lang: str, formality: str = None):
"""
Upload a document to DeepL, poll status, download the translated file.
Returns the output file path.
"""
# 1) Upload
url_upload = f"{base_url}/v2/document"
files = {"file": (src_file.name, open(src_file, "rb"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
data = {"target_lang": target_lang}
if source_lang:
data["source_lang"] = source_lang
if formality: # e.g., "more", "less", "prefer_more", "prefer_less"
data["formality"] = formality
headers = {"Authorization": f"DeepL-Auth-Key {api_key}"}
resp = requests.post(url_upload, files=files, data=data, headers=headers, timeout=60)
if resp.status_code != 200:
raise RuntimeError(f"Upload failed: {resp.status_code} {resp.text}")
payload = resp.json()
document_id = payload["document_id"]
document_key = payload["document_key"]
# 2) Poll
url_status = f"{base_url}/v2/document/{document_id}"
start_time = time.time()
while True:
resp = requests.post(url_status, data={"document_key": document_key}, headers=headers, timeout=60)
if resp.status_code != 200:
raise RuntimeError(f"Status failed: {resp.status_code} {resp.text}")
st = resp.json()
status = st.get("status", "")
secs = int(time.time() - start_time)
yield ("status", f"{status} ({secs}s)")
if status == "done":
break
if status in ("error", "failed"):
message = st.get("message", "Unknown error")
raise RuntimeError(f"Translation failed: {message}")
if time.time() - start_time > TIMEOUT_SECONDS:
raise TimeoutError("Timed out waiting for DeepL to finish.")
time.sleep(DEFAULT_POLL_SECONDS)
# 3) Download result
url_result = f"{base_url}/v2/document/{document_id}/result"
resp = requests.post(url_result, data={"document_key": document_key}, headers=headers, timeout=120)
if resp.status_code != 200:
raise RuntimeError(f"Download failed: {resp.status_code} {resp.text}")
out_dir.mkdir(parents=True, exist_ok=True)
out_name = f"{src_file.stem}.translated.{target_lang}.docx"
out_path = out_dir / out_name
with open(out_path, "wb") as f:
f.write(resp.content)
yield ("done", str(out_path))
# ---------- GUI ----------
class App:
def __init__(self, root: Tk):
self.root = root
self.root.title(APP_TITLE)
self.root.geometry("720x520")
self.root.minsize(680, 500)
# variables
load_dotenv() # load .env if present
self.api_key = StringVar(value=os.getenv("DEEPL_API_KEY", ""))
self.use_free = BooleanVar(value=False) # force api-free
self.source_lang = StringVar(value=SOURCE_LANGS[0][1]) # ""
self.target_lang = StringVar(value="EN")
self.input_path = StringVar(value="")
self.output_dir = StringVar(value=str(Path.home() / "deepl_translations"))
self.formality = StringVar(value="") # "", "more", "less", "prefer_more", "prefer_less"
# layout
pad = {"padx": 10, "pady": 8}
frm = ttk.Frame(root)
frm.pack(fill="both", expand=True)
row = 0
ttk.Label(frm, text="DeepL API Key:").grid(row=row, column=0, sticky="e", **pad)
self.api_entry = ttk.Entry(frm, textvariable=self.api_key, show="•", width=50)
self.api_entry.grid(row=row, column=1, sticky="we", **pad, columnspan=2)
self.show_key = BooleanVar(value=False)
chk = ttk.Checkbutton(frm, text="Show", variable=self.show_key, command=self._toggle_key)
chk.grid(row=row, column=3, sticky="w", **pad)
row += 1
ttk.Checkbutton(frm, text="Use Free API (api-free.deepl.com)", variable=self.use_free).grid(row=row, column=1, sticky="w", **pad, columnspan=2)
row += 1
ttk.Label(frm, text="Source language:").grid(row=row, column=0, sticky="e", **pad)
self.src_combo = ttk.Combobox(frm, state="readonly", values=[name for name, code in SOURCE_LANGS])
self.src_combo.grid(row=row, column=1, sticky="we", **pad)
self.src_combo.current(0)
self.src_combo.bind("<<ComboboxSelected>>", self._on_source_select)
ttk.Label(frm, text="Target language:").grid(row=row, column=2, sticky="e", **pad)
self.tgt_combo = ttk.Combobox(frm, state="readonly", values=[name for name, code in TARGET_LANGS])
self.tgt_combo.grid(row=row, column=3, sticky="we", **pad)
# default to EN
for i,(name, code) in enumerate(TARGET_LANGS):
if code == "EN":
self.tgt_combo.current(i)
break
self.tgt_combo.bind("<<ComboboxSelected>>", self._on_target_select)
row += 1
ttk.Label(frm, text="Formality (optional):").grid(row=row, column=0, sticky="e", **pad)
self.form_combo = ttk.Combobox(frm, state="readonly",
values=["", "more", "less", "prefer_more", "prefer_less"])
self.form_combo.grid(row=row, column=1, sticky="we", **pad)
self.form_combo.current(0)
self.form_combo.bind("<<ComboboxSelected>>", self._on_formality_select)
row += 1
ttk.Label(frm, text="Input .docx:").grid(row=row, column=0, sticky="e", **pad)
self.in_entry = ttk.Entry(frm, textvariable=self.input_path, width=50)
self.in_entry.grid(row=row, column=1, sticky="we", **pad)
ttk.Button(frm, text="Browse…", command=self._choose_input).grid(row=row, column=2, sticky="w", **pad)
row += 1
ttk.Label(frm, text="Output folder:").grid(row=row, column=0, sticky="e", **pad)
self.out_entry = ttk.Entry(frm, textvariable=self.output_dir, width=50)
self.out_entry.grid(row=row, column=1, sticky="we", **pad)
ttk.Button(frm, text="Select…", command=self._choose_output).grid(row=row, column=2, sticky="w", **pad)
row += 1
self.btn_translate = ttk.Button(frm, text="Translate", command=self._start_translation)
self.btn_translate.grid(row=row, column=1, sticky="we", **pad)
ttk.Button(frm, text="Quit", command=root.destroy).grid(row=row, column=2, sticky="we", **pad)
# progress + log
row += 1
self.pbar = ttk.Progressbar(frm, mode="indeterminate")
self.pbar.grid(row=row, column=0, columnspan=4, sticky="we", **pad)
row += 1
ttk.Label(frm, text="Status:").grid(row=row, column=0, sticky="ne", **pad)
self.log = ttk.Treeview(frm, columns=("msg",), show="tree", height=10)
self.log.grid(row=row, column=1, columnspan=3, sticky="nsew", **pad)
# stretch
frm.columnconfigure(1, weight=1)
frm.columnconfigure(3, weight=1)
frm.rowconfigure(row, weight=1)
# --- GUI handlers ---
def _toggle_key(self):
self.api_entry.config(show="" if self.show_key.get() else "•")
def _on_source_select(self, _evt=None):
idx = self.src_combo.current()
self.source_lang.set(SOURCE_LANGS[idx][1])
def _on_target_select(self, _evt=None):
idx = self.tgt_combo.current()
self.target_lang.set(TARGET_LANGS[idx][1])
def _on_formality_select(self, _evt=None):
self.formality.set(self.form_combo.get().strip() or "")
def _choose_input(self):
path = filedialog.askopenfilename(
title="Choose a DOCX file",
filetypes=[("Word Document", "*.docx")],
)
if path:
self.input_path.set(path)
def _choose_output(self):
path = filedialog.askdirectory(title="Choose output folder")
if path:
self.output_dir.set(path)
def _log(self, text: str):
self.log.insert("", "end", text=text)
self.log.see(self.log.get_children()[-1])
def _start_translation(self):
# validate
api_key = self.api_key.get().strip()
if not api_key:
messagebox.showerror("Missing API key", "Please paste your DeepL API key.")
return
src = Path(self.input_path.get().strip())
if not src.exists() or src.suffix.lower() != ".docx":
messagebox.showerror("Invalid file", "Please choose a valid .docx file.")
return
out_dir = Path(self.output_dir.get().strip()) if self.output_dir.get().strip() else Path.cwd()
target_lang = self.target_lang.get().strip()
source_lang = self.source_lang.get().strip()
formality = self.formality.get().strip() or None
base_url = infer_base_url(api_key, self.use_free.get())
# disable UI, start progress
self.btn_translate.config(state="disabled")
self.pbar.start(10)
self._log(f"Starting… Target={target_lang}, Source={'auto' if not source_lang else source_lang}, Endpoint={base_url}")
def worker():
try:
for kind, msg in translate_doc(api_key, base_url, src, out_dir, target_lang, source_lang, formality):
if kind == "status":
self.root.after(0, lambda m=msg: self._log(f"Status: {m}"))
elif kind == "done":
self.root.after(0, lambda m=msg: self._log(f"Done → {m}"))
self.root.after(0, lambda: messagebox.showinfo("Success", "Translation complete!"))
except Exception as e:
err = f"{type(e).__name__}: {e}"
tb = traceback.format_exc()
self.root.after(0, lambda: self._log(err))
self.root.after(0, lambda: self._log(tb))
self.root.after(0, lambda: messagebox.showerror("Error", err))
finally:
self.root.after(0, lambda: self.btn_translate.config(state="normal"))
self.root.after(0, lambda: self.pbar.stop())
threading.Thread(target=worker, daemon=True).start()
def main():
root = Tk()
style = ttk.Style()
try:
style.theme_use("clam")
except:
pass
App(root)
root.mainloop()
if __name__ == "__main__":
main()
.docx
to DeepL via POST /v2/document
, passing target_lang
(and optional source_lang
/ formality
).document_id
and document_key
.POST /v2/document/{document_id}
every 2 seconds until status is done
.POST /v2/document/{document_id}/result
.YourFile.translated.<LANG>.docx
.:fx
, it’s likely a Free key → the app automatically selects api-free.deepl.com
. You can also tick “Use Free API” manually.more/less/prefer_more/prefer_less
. Leave blank if unsure..pptx
, .xlsx
). You can add them by adjusting the file dialog and MIME type.