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.
Updated / Extended the script/
DeepL Translation Script for Scribus – Auto Source Version
What it does in v4:
- Lets DeepL automatically detect the source language per frame
- You only choose:
- What to translate (selected / current page / whole document)
- Target language
- Translates all applicable text frames in scope
- Preserves:
- Font
- Font size
- Text color
- Line spacing
- Alignment
- Paragraph style
- Base character style
- Word-level character styles (e.g. bold words)
- Optional preview mode
- Logfile: scribus_translation_log.txt (next to the .sla document)
Note: The script is in the attachted ZIP file.
Interesting work!
Would you mind attaching a license to it? : - )
Hi everyone,
I've updated my DeepL translation script for Scribus with a nicer preview mode:
Preview now actually calls DeepL, but doesn't change anything in the document.
It shows how many frames and words would be translated.
It also shows, in the preview window, example word → translation pairs for a few frames (e.g. Bonjour -> Hello, Monde -> World).
Full details (including all mappings) are still written to scribus_translation_log.txt.
This makes it much easier to verify the translation quality and check terminology before you let the script modify the document.
The updated script is attached.
Quote from: a.l.e on November 18, 2025, 07:34:51 PMInteresting work!
Would you mind attaching a license to it? : - )
I have added the MIT license to the code but if you mean the DeepL license you can get a free license, up to 500,0000 chars per month, at deepl.com, no strings attached :)
Updated DeepL Auto-Translation Script for Scribus — Now with a Full Tkinter GUI!
Hi everyone,
I've released a major update to the Scribus DeepL Auto-Translation Script, and I'm excited to share what's new. The script now includes a full Tkinter-based graphical interface, making it far easier and more intuitive to use. No more typing codes into valueDialogs — everything is clean, consistent, and user-friendly.
What's new?
Modern Tkinter (see attached screenshot) interface with:
-Dropdown menus for languages
-Checkboxes for preview mode
-Buttons for actions (Translate / Preview / About / Quit)
-Cleaner layout and better usability
-Emoji support on most systems
Overall a much more polished and consistent experience
Expanded language support
I've added several new DeepL-supported languages:
Polish (PL)
Russian (RU)
Ukrainian (UK)
Greek (EL)
Swedisch (SV)
Chinese Simplified (ZH)
(Alongside DE, EN, FR, ES, IT, NL)
Other improvements
-Better error handling
-Clear About window describing the script
-Cleaner code structure
-More future-proof and easier to extend
Optional preview mode that shows what would change without modifying the document
If anyone wants to test it, give feedback, or request new features — please let me know!
Happy translating! 🌐
Bastiaan
Hi,
Update: Scribus Automated Translation Script – Version 0.10
What's new in v0.10
• Expanded language support
Additional languages have been added, including several non-Latin scripts. This improves coverage for multilingual layout projects and ensures broader accessibility.
• Improved interface and usability
The script now provides a clearer, more intuitive workflow, making it easier to select languages, configure options, and run translations without manual adjustments.
• Better formatting preservation
We enhanced the logic that handles styles, spacing, and text attributes. The script now keeps more of the original Scribus formatting intact during translation, reducing cleanup time.
• More reliable DeepL integration
A cleaner, more robust DeepL request/response handling has been implemented. Error handling is improved, and support for larger text blocks is now more stable.
What's next
I am are currently working on additional refinements, including better support for right-to-left languages and more advanced formatting retention.
If you have suggestions, issues, or ideas for future improvements, feel free to share them.
Bastiaan