DeepL Translation Script for Scribus. Preserves Formatting, Colors, Alignment

Previous topic - Next topic

bastiaanw

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()


bastiaanw

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.