Hi everyone,
I've created a Python script for Scribus that uses the DeepL API to automatically translate text frames while preserving all formatting. I am sharing it with the community in case it helps anyone working with multilingual documents.
Features
Translate selected (you can select multiple text frames) text frames or all text frames on current page
Uses DeepL API (Free or Pro)
Their free plan has a maximum of 50,000 words per month.
Preserves per-frame formatting (as much as possbile):
Font, size, text color, line spacing, alignment
Paragraph style
Word-level bold/character styling (e.g. "RED" → "ROUGE" stays bold)
Mixed-language detection (optional)
Menu system with numeric choices (compatible with Scribus scripting dialogs)
Built-in "Test DeepL connection" option
Supported languages
EN, NL, FR, DE, ES, IT (easy to extend)
Why I built it
I needed a reliable way to translate packaging and manuals across multiple languages without losing formatting. Existing tools either lost text styles or required manual cleanup, so I wrote this script to automate the process cleanly. And since AI is here to make our lives easier, I used AI for parts of the code.
Copy the python code below and save it as: scribus_auto_translate.py.
Open a document you want to translate in Scribus, and:
Script → Execute Script (choose the scribus_auto_translate.py)
The Code:
#!/usr/bin/env python
#
# Simple Python script to translate Sribus documents
# into various languages.
# Currently it supports the following languages:
# English, French, Italian, German, Spanish and Dutch
#
#
# Bastiaan Williams.
# bastiaanwilliams@protomail.com
import scribus
import urllib.request
import urllib.parse
import urllib.error
import json
# -----------------------------
# CONFIGURATION
# -----------------------------
DEEPL_API_KEY = "YOUR_OWN_API_KEY" # <-- fill in your own DeepL API key
DEEPL_URL = "https://api-free.deepl.com/v2/translate"
SUPPORTED_LANGS = {
"EN": "EN",
"NL": "NL",
"FR": "FR",
"DE": "DE",
"ES": "ES",
"IT": "IT"
}
LANG_HINT_WORDS = {
"EN": ["the", "and", "you", "with", "for", "to", "in"],
"NL": ["de", "het", "een", "en", "met", "niet", "dat"],
"FR": ["le", "la", "et", "les", "des", "une", "un"],
"DE": ["und", "die", "der", "das", "ist", "nicht"],
"ES": ["el", "la", "los", "las", "que", "para"],
"IT": ["il", "la", "che", "per", "non", "con"]
}
# Only translate frames that look like they are written in the source language
USE_LANGUAGE_FILTER = True
# -----------------------------
# HTTP / DEEPL HELPER
# -----------------------------
def deepl_request(params):
"""
Low level DeepL HTTP helper.
Returns JSON dict on success,
returns None on fatal error (401/403/429)
and shows message to the user.
"""
if not DEEPL_API_KEY or DEEPL_API_KEY == "YOUR_KEY_HERE":
scribus.messageBox(
"DeepL Error",
"Please set your DeepL API key in the script.",
scribus.ICON_WARNING
)
return None
params["auth_key"] = DEEPL_API_KEY
data = urllib.parse.urlencode(params).encode("utf-8")
try:
req = urllib.request.Request(DEEPL_URL, data=data)
f = urllib.request.urlopen(req)
js = json.loads(f.read().decode("utf-8"))
return js
except urllib.error.HTTPError as e:
code = e.code
if code == 429:
scribus.messageBox(
"DeepL limit reached",
"DeepL HTTP 429: Too many requests / quota used.\n"
"Translation will stop.",
scribus.ICON_WARNING
)
return None
if code in (401, 403):
scribus.messageBox(
"DeepL authentication / permission error",
(
"DeepL HTTP %d.\n"
"This usually means:\n"
"- Invalid API key\n"
"- Wrong endpoint (free vs pro)\n"
"- Or your account has no access.\n"
"Translation will stop."
) % code,
scribus.ICON_WARNING
)
return None
scribus.messageBox("DeepL HTTP Error", str(e), scribus.ICON_WARNING)
return None
except Exception as e:
scribus.messageBox("DeepL Error", str(e), scribus.ICON_WARNING)
return None
# -----------------------------
# API TEST
# -----------------------------
def test_deepl_connection():
"""
Sends a tiny test request to DeepL and reports if the API key works.
"""
params = {
"text": "Test",
"target_lang": "EN"
}
js = deepl_request(params)
if js is None:
return
try:
translated = js["translations"][0]["text"]
except Exception:
translated = "<could not read DeepL response>"
scribus.messageBox(
"DeepL Test",
"DeepL connection seems OK.\n"
"Sample translation result:\n\n%s" % translated,
scribus.ICON_INFORMATION
)
# -----------------------------
# TRANSLATION ENGINE
# -----------------------------
def translate_text(text, src, tgt):
"""Call DeepL for normal translation. Return None on fatal error."""
src_code = SUPPORTED_LANGS.get(src)
tgt_code = SUPPORTED_LANGS.get(tgt)
if not src_code or not tgt_code:
scribus.messageBox(
"Error",
"Invalid language code.",
scribus.ICON_WARNING
)
return text
params = {
"text": text,
"target_lang": tgt_code
}
if src_code != tgt_code:
params["source_lang"] = src_code
js = deepl_request(params)
if js is None:
return None
try:
return js["translations"][0]["text"]
except Exception as e:
scribus.messageBox("DeepL Parse Error", str(e), scribus.ICON_WARNING)
return text
# -----------------------------
# LANGUAGE DETECTION
# -----------------------------
def detect_lang_simple(text):
"""Very simple language guess based on a few common words."""
txt = text.lower()
for ch in ",.;:!?()[]{}'\"":
txt = txt.replace(ch, " ")
words = txt.split()
best = None
best_score = 0
for code, hints in LANG_HINT_WORDS.items():
score = 0
for w in words:
if w in hints:
score += 1
if score > best_score:
best = code
best_score = score
if best_score == 0:
return None
return best
# -----------------------------
# WORD + STYLE HELPERS
# -----------------------------
def get_words_with_positions(text):
"""
Split text into words by whitespace.
Return list of (start_index, length) per word.
"""
words = []
start = None
for i, ch in enumerate(text):
if ch.isspace():
if start is not None:
words.append((start, i - start))
start = None
else:
if start is None:
start = i
if start is not None:
words.append((start, len(text) - start))
return words
def detect_word_styles(frame_name, text, base_cstyle):
"""
For each word, detect if it has a uniform character style
different from the base style. Returns: word_index -> style_name.
"""
style_map = {}
try:
total_len = scribus.getTextLength(frame_name)
except Exception:
total_len = len(text)
char_styles = []
for i in range(total_len):
try:
scribus.selectText(i, 1, frame_name)
s = scribus.getCharacterStyle(frame_name)
except Exception:
s = base_cstyle
char_styles.append(s)
# reset selection
try:
scribus.selectText(0, 0, frame_name)
except Exception:
pass
word_positions = get_words_with_positions(text)
for idx, (start, length) in enumerate(word_positions):
if start + length > len(char_styles):
continue
styles_in_word = char_styles[start:start + length]
if not styles_in_word:
continue
first_style = styles_in_word[0]
if all(s == first_style for s in styles_in_word):
if first_style and first_style != base_cstyle:
style_map[idx] = first_style
return style_map
def apply_word_styles(frame_name, text, word_style_map):
"""
Apply character styles from word_style_map (word_index -> style_name)
to corresponding words in the given text.
"""
word_positions = get_words_with_positions(text)
for idx, (start, length) in enumerate(word_positions):
if idx not in word_style_map:
continue
style_name = word_style_map[idx]
try:
scribus.selectText(start, length, frame_name)
scribus.setCharacterStyle(style_name, frame_name)
except Exception:
continue
# clear selection
try:
scribus.selectText(0, 0, frame_name)
except Exception:
pass
# -----------------------------
# FRAME PROCESSING
# -----------------------------
def process_frames(frames, src, tgt):
"""
Translate given frames, preserving:
- font
- font size
- text color
- alignment
- line spacing (leading)
- paragraph style
- base character style
- word-level character styles (e.g. bold words)
"""
translated = 0
skipped = 0
src_norm = src # ES stays ES
for frame_name in frames:
text = scribus.getText(frame_name)
if not text.strip():
continue
# --- language filter ---
if USE_LANGUAGE_FILTER:
detected = detect_lang_simple(text)
if detected and detected != src_norm:
skipped += 1
continue
# --- remember base styles / font / color / line spacing / alignment ---
try:
base_font = scribus.getFont(frame_name)
except Exception:
base_font = None
try:
base_size = scribus.getFontSize(frame_name)
except Exception:
base_size = None
try:
base_color = scribus.getTextColor(frame_name)
except Exception:
base_color = None
try:
base_linespacing = scribus.getLineSpacing(frame_name)
except Exception:
base_linespacing = None
try:
base_align = scribus.getAlignment(frame_name)
except Exception:
base_align = None
try:
base_pstyle = scribus.getParagraphStyle(frame_name)
except Exception:
base_pstyle = None
try:
base_cstyle = scribus.getCharacterStyle(frame_name)
except Exception:
base_cstyle = ""
if base_cstyle is None:
base_cstyle = ""
# --- detect word-level styles in source text ---
word_style_map = detect_word_styles(frame_name, text, base_cstyle)
# --- translate ---
new_text = translate_text(text, src, tgt)
if new_text is None:
# fatal DeepL error, stop
break
scribus.setText(new_text, frame_name)
# --- re-apply base font / size / color / line spacing / alignment / styles ---
try:
total_len = scribus.getTextLength(frame_name)
except Exception:
total_len = len(new_text)
if total_len > 0:
try:
scribus.selectText(0, total_len, frame_name)
except Exception:
pass
if base_font:
try:
scribus.setFont(base_font, frame_name)
except Exception:
pass
if base_size is not None:
try:
scribus.setFontSize(base_size, frame_name)
except Exception:
pass
if base_color:
try:
scribus.setTextColor(base_color, frame_name)
except Exception:
pass
if base_linespacing is not None:
try:
scribus.setLineSpacing(base_linespacing, frame_name)
except Exception:
pass
if base_align is not None:
try:
scribus.setAlignment(base_align, frame_name)
except Exception:
pass
if base_pstyle:
try:
scribus.setParagraphStyle(base_pstyle, frame_name)
except Exception:
pass
if base_cstyle:
try:
scribus.setCharacterStyle(base_cstyle, frame_name)
except Exception:
pass
# clear selection
try:
scribus.selectText(0, 0, frame_name)
except Exception:
pass
# --- apply word-level styles on translated text ---
apply_word_styles(frame_name, new_text, word_style_map)
translated += 1
msg = (
"Translation finished.\n\n"
"Translated frames: %d\n"
"Skipped (different language): %d"
) % (translated, skipped)
scribus.messageBox("Done", msg, scribus.ICON_NONE)
# -----------------------------
# MENUS (NUMBER-BASED)
# -----------------------------
# Note: The Scribus Python interpreter does not support
# dropdown menus or custom GUI buttons.
# Only basic dialogs like messageBox() and valueDialog() are available.
def choose_action():
"""
Step 0 – What do you want to do?
[1] Test DeepL
[2] Translate
[3] Cancel
"""
text = (
"What do you want to do?\n\n"
"[1] Test DeepL connection (small test request)\n"
"[2] Translate text frames\n"
"[3] Cancel\n\n"
"Type 1, 2 or 3:"
)
choice = scribus.valueDialog(
"Action Menu",
text,
"2"
)
if not choice:
return None
choice = choice.strip()
if choice == "1":
return "test"
if choice == "2":
return "translate"
return None
def choose_mode():
"""
Step 1 – What to translate?
[1] Whole current page
[2] Selected text frames
[3] Cancel
"""
text = (
"Step 1/3 – What do you want to translate?\n\n"
"[1] Whole current page (all text frames on this page)\n"
"[2] Selected text frames only\n"
"[3] Cancel\n\n"
"Type 1, 2 or 3:"
)
choice = scribus.valueDialog(
"Translation Mode",
text,
"1"
)
if not choice:
return None
choice = choice.strip()
if choice == "1":
return "page"
if choice == "2":
return "selected"
return None
def choose_language(title, default_code="EN"):
text = (
"Choose language code:\n\n"
"EN = English\n"
"NL = Dutch\n"
"FR = French\n"
"DE = German\n"
"ES = Spanish\n"
"IT = Italian\n"
)
lang = scribus.valueDialog(
title,
text,
default_code
)
if not lang:
return None
lang = lang.strip().upper()
if lang in SUPPORTED_LANGS:
return lang
scribus.messageBox(
"Error",
"Invalid language code. Use EN, NL, FR, DE, ES, or IT.",
scribus.ICON_WARNING
)
return None
# -----------------------------
# MAIN
# -----------------------------
def main():
if not scribus.haveDoc():
scribus.messageBox(
"Error",
"Open a document first.",
scribus.ICON_WARNING
)
return
action = choose_action()
if action is None:
return
if action == "test":
test_deepl_connection()
return
# action == "translate"
mode = choose_mode()
if not mode:
return
src = choose_language("Step 2/3 – Source language", "EN")
if not src:
return
tgt = choose_language("Step 3/3 – Target language", "IT")
if not tgt:
return
if mode == "page":
frames = [
item[0] for item in scribus.getPageItems()
if item[1] == 4
]
if not frames:
scribus.messageBox(
"Info",
"No text frames found on this page.",
scribus.ICON_INFORMATION
)
return
process_frames(frames, src, tgt)
elif mode == "selected":
if scribus.selectionCount() == 0:
scribus.messageBox(
"Error",
"No frames selected.",
scribus.ICON_WARNING
)
return
frames = []
for i in range(scribus.selectionCount()):
name = scribus.getSelectedObject(i)
if scribus.getObjectType(name) == "TextFrame":
frames.append(name)
if not frames:
scribus.messageBox(
"Error",
"No selected text frames.",
scribus.ICON_WARNING
)
return
process_frames(frames, src, tgt)
if __name__ == "__main__":
main()
If some text frames are not being translated, try expanding the LANG_HINT_WORDS list. Adding more common words improves language detection and helps the script identify which frames should be translated.
Extended example:
LANG_HINT_WORDS = {
"EN": ["the", "and", "you", "with", "for", "to", "in", "of", "on", "off", "press", "hold"],
"NL": ["de", "het", "een", "en", "met", "niet", "dat", "om", "aan", "uit", "druk"],
"FR": ["le", "la", "et", "les", "des", "une", "un", "appuyez", "sur", "pour"],
"DE": ["und", "die", "der", "das", "ist", "nicht", "ein", "eine", "zum"],
"ES": ["el", "la", "los", "las", "que", "para", "en", "presione"],
"IT": ["il", "la", "che", "per", "non", "con", "un", "premi", "tenere"]
}
Or Change:
USE_LANGUAGE_FILTER = True -> USE_LANGUAGE_FILTER = False
To turn the language filter off.