LibreOffice plugin to pipe whole Writer documents through Google Translate, that ought to keep most of the page formatting.

⌈⌋ branch:  PageTranslate

Artifact [500cbd9eec]

Artifact 500cbd9eec67c09846226bfc4fbd4a52a58ccccb:

  • File — part of check-in [3f945d5495] at 2021-06-10 14:50:23 on branch trunk — Move MessageBox() to unocompat (not actually used anymore, doesn't work in LO-dev-7.2 anyway), sys.excepthook doesn't suffice for dialog hookup. Add config btn_map{} for external tools from settings dialog. (user: mario size: 17523)

# encoding: utf-8
# api: uno
# type: callback
# category: language
# title: PageTranslate
# description: Action button to get whole Writer document translated
# version: 1.9.74
# state: beta
# author: mario
# url:
# depends: python:requests (>= 2.5), python:uno
# pack: *.py, pythonpath/*.py, META-INF/*, pkg-desc, *.x*, icons/*
# config:
#    { name: frames, type: bool, value: 0, description: traverse TextFrames }
#    { name: quick, type: bool, value: 0, description: newline placeholders }
#    { name: slow, type: bool, value: 0, description: traverse TextPortions }
#    { name: debug, type: bool, value: 1, description: default logging level }
#    { name: flag, type: str, value: "locale", description: second btn action }
# license: GNU LGPL 2.1
# forked-from: TradutorLibreText (Claudemir de Almeida Rosa)
# LibreOffice plugin for translating documents that's supposed to retain formatting.
# Per default does not require a text selection to operate, but works on the whole
# page.
# The original mode (TradutorLibreText) is still supported and used whenever a text
# portion is selected. It also uses the default target language (English) then.
# Unless a different mode/language from the Tools>PageTranslate menu is requested.
# Beware that Writer freezes during the dozens of translation calls to Google.
# In particular long documents might take ages, because each paragraph/line or
# text longer 1900 chars causes another roundtrip.
# Basic support for Draw/Impress documents is now provided. (No text selection
# mode there however).
# There's a configuration dialog now, under Tools→Options→[Language→PageTranslate].
# Where you can switch the translation service, and set a few options. You'll
# need an API key for DeepL API or Microsoft Translator. Or set an email for
# MyMemory, or a command for using a CLI translation program. Other services
# are provides by deep-translator or translate-python. (Might require a `pip
# install` each, unless you install a bundled 20MB extension release.)
# Always creates a log file: /tmp/pagetranslate-libreoffice.log
# Without pythonpath/ populated, this plugin won't work on Windows installations
# fully (only the Google Translate option is likely to).

# OpenOffice UNO bridge
import uno, unohelper
from import XJobExecutor
from unocompat import PropertyValue, XNamedAsEnumeration, MessageBox
from import XActionListener, XContainerWindowEventHandler
from import Locale, XServiceInfo, XInitialization
# core modules
import os, sys
import string, json, re
from traceback import format_exc
from tempfile import gettempdir
# log setup
import logging as log
log.basicConfig(filename='%s/pagetranslate-libreoffice.log'%gettempdir(), level=log.DEBUG)
sys.excepthook = lambda *exc: log.critical(format_exc())
# pythonpath/*.py modules
import httprequests
import translationbackends
import pt_dialogs

# Office plugin
class pagetranslate(unohelper.Base, XJobExecutor):

    # defaults + config + command args
    params = dict(
        mode = "page",      # "trigger"/"page", or "tradutor"
        lang = "en",        # target language, or "flag", or "paragraph", "locale", "select", "mri-debug"
        frames = 0,         # also process TextFrames (subdocuments)
        quick = 0,          # use temporary newline placeholders, or split/iterate over text sections
        slow = 0,           # further split over paragraph segments/formatting (super slow mode)
        debug = 1,          # logging level
        backend = "Google", # backend to use, (string name replaces old flags)
        api_key = "",       # API key
        email = "",         # MyMemory email
        cmd = "translate-cli -o -f auto -t {lang} {text}",  # cli tool
        flag = "locale",    # default lang for secondary 🏴 button
    t = None   #=

    # gets instantiated as XJobExecutor by LibreOffice
    def __init__(self, ctx):"init")
        self.ctx = ctx
        self.desktop = self.ctx.ServiceManager.createInstanceWithContext( "", self.ctx )
        self.document = self.desktop.getCurrentComponent()
        #self.dispatcher = self.ctx.ServiceManager.createInstanceWithContext("", self.ctx)

    # invoked from toolbar button
    def trigger(self, args):".trigger(args='%s') invoked" % repr(args))
            # merge defaults from registry + params from args
            self.params["from"] = "auto"
            if self.params.get("debug"):
            if self.params.get("mode") == "mri":
            if self.params.get("lang") == "flag":
                self.params["lang"] = self.params.get("flag", "locale")
            if self.params.get("lang") == "select" or self.params.get("from") == "select":
                self.params["from"], self.params["lang"] = pt_dialogs.langselect()
            if self.params.get("lang") in ("mri-debug", "mri", "debug"):
                return self.mri(self.document)

            # Draw/Impress?
            if self.document.supportsService("") or self.document.supportsService(""):

            # check for text selection, and switch to TradutorLibreText method then
            selection = self.document.getCurrentController().getSelection().getByIndex(0)
            if len(selection.getString()):

            # else iterate over text snippets
            tree = self.document.getText().createEnumeration()
            self.traverse(tree, slow=self.params.get("slow"))
            if self.params.get("frames"):
        # show message box for errors from wherever
        except Exception as exc:

    # central handler for errors
    def exc(self, exc, *a, **kw):
        dump = format_exc()
        pt_dialogs.exception(err=str(exc), exc=dump)
        #    MessageBox(self, dump)

    # map self.t.translate() implementation according to settings
    def assign_t(self):
        self.t = translationbackends.assign_service(self.params)
    # break up UNO service: url query string `.pagetranslate?page&lang=en`
    def argparse(self, args):
        # parameterize leading ?action&
        args = "mode=" + args
        # key=value pairs
        params = dict(re.findall("(\w+)=([\w-]+)", args))
        # replace default locale
        if params.get("lang","-") == "locale":
            params["lang"] = self.getOoLocale()
        return params

    # debugging/introspection
    def mri(self, obj):
        mri = self.ctx.ServiceManager.createInstanceWithContext("mytools.Mri", self.ctx)

    #-- iterate over TextContent/TextTable nodes
    def traverse(self, tree, slow=0):"TextDocument.Enumeration…")
        while tree.hasMoreElements():
            para = tree.nextElement()
            # table/cells
            if para.supportsService(""):
                for cellname in para.getCellNames():
                    text = para.getCellByName(cellname).getText()
                    text.setString(self.t.linebreakwise(text.getString())) # or .translate #linebreakwise
            # subdocuments?
            elif para.supportsService(""):
            # a paragraph can be further enumerated for text portions (same character/style attributes),
            # but that will obviously slow things down further / also complicate coherent translations
            elif slow and para.supportsService(""): # doesn't work with
                self.traverse(para.createEnumeration(), slow=0)  # list of TextPortion`s
            # normal flow text / paragraph
            elif para.supportsService("") or para.supportsService(""):
                text = para.getString()
                text = self.t.translate(text)
                log.warning("Unsupported document element.")

    #-- iterate over DrawPages and TextShapes
    def drawtranslate(self, pages):
        for pi in range(0, pages.getCount()):
            page = pages.getByIndex(pi)
            for si in range(0, page.getCount()):
                shape = page.getByIndex(si)
                if shape.supportsService(""):

    #-- TradutorLibreText (selection rewrite)
    def rewrite_selection(self, xTextRange):"rewrite text selection")

        # Get selected text and language
        string = xTextRange.getString()
        if self.params["lang"] == "paragraph":
            self.params["lang"] = xTextRange.CharLocale.Language
        elif self.params["mode"] == "tradutor":
            code = self.getOoLocale()
            self.params["lang"] = self.getParaLang(xTextRange).Language

        # we kinda have to reinstantiate the backend late, because params` lang= might be hard-applied to handler (e.g. translate-python)

        # translate/replace (plain text) with linebreaks intact
        trans = self.t.linebreakwise(string)
        trans = trans.replace('\\n',"\n").replace('\\r',"\n")".setString from '"+string+"' to ("+self.params["lang"]+")='"+trans+"'")

    # Query system locale
    def getOoLocale(self):
        self.LocaleData = self.ctx.ServiceManager.createInstanceWithContext("", self.ctx)
        L10Ncfg = self.ctx.ServiceManager.createInstanceWithContext("", self.ctx)
        nodepath = PropertyValue(Name="nodepath", Value="/org.openoffice.Setup/L10N")
        code = L10Ncfg.createInstanceWithArguments("", (nodepath,)).getByName("ooLocale")"ooLocale="+repr(code))
        return code

    # Langinfo=({ Language = (string)"de", LanguageDefaultName = (string)"German", Country = (string)"DE", CountryDefaultName = (string)"Germany", Variant = (string)"" }
    def getParaLang(self, xTextRange):
        Langinfo = self.LocaleData.getLanguageCountryInfo(xTextRange.CharLocale)"Langinfo="+repr(Langinfo))
        return Langinfo

# XActionListener for callbacks
class action_listener(unohelper.Base, XActionListener):
    def __init__(self, cb):
        self.actionPerformed = cb

# Handler for settings-embedded DialogOptions.xdl window, and read/write access to our leaf in the office registry.
# (This is fairly generic/reusable, because it directly maps a dict to/from the dialog widgets.)
class settings(unohelper.Base, XContainerWindowEventHandler, XServiceInfo):
    impl_id = "vnd.include-once.OptionsPageTranslate"
    btn_map = {
        "cfg_argos": "PYTHONPATH= argos-translate-gui &",
        "cfg_deps": "x-terminal-emulator -c 'pip install -U requests translate deep-translator argos-translate' &",

    def __init__(self, ctx, *kargs):"OptionsPageTranslate:settings.__init__()")
        self.access = self.updatemgr(ctx)
        log.debug(dir(self.access)) #→ ['AsProperty', 'ElementNames', 'ElementType', 'HierarchicalName', 'HierarchicalPropertySetInfo', 'ImplementationId', 'ImplementationName', 'Name', 'PendingChanges', 'Properties', 'PropertySetInfo', 'SupportedServiceNames', 'Types', 'addChangesListener', 'addContainerListener', 'addEventListener', 'addPropertiesChangeListener', 'addPropertyChangeListener', 'addVetoableChangeListener', 'api_key', 'api_key', 'commitChanges', 'composeHierarchicalName', 'debug', 'debug', 'deepl_api', 'deepl_api', 'deepl_web', 'deepl_web', 'dispose', 'firePropertiesChangeEvent', 'getAsProperty', 'getByHierarchicalName', 'getByName', 'getElementNames', 'getElementType', 'getExactName', 'getHierarchicalName', 'getHierarchicalPropertySetInfo', 'getHierarchicalPropertySetInfo', 'getHierarchicalPropertyValue', 'getHierarchicalPropertyValues', 'getImplementationId', 'getImplementationName', 'getName', 'getPendingChanges', 'getProperties', 'getPropertyByHierarchicalName', 'getPropertyByName', 'getPropertySetInfo', 'getPropertySetInfo', 'getPropertyValue', 'getPropertyValues', 'getSupportedServiceNames', 'getTypes', 'google', 'google', 'hasByHierarchicalName', 'hasByName', 'hasElements', 'hasPendingChanges', 'hasPropertyByHierarchicalName', 'hasPropertyByName', 'queryAdapter', 'queryInterface', 'removeChangesListener', 'removeContainerListener', 'removeEventListener', 'removePropertiesChangeListener', 'removePropertyChangeListener', 'removeVetoableChangeListener', 'replaceByHierarchicalName', 'replaceByName', 'setHierarchicalPropertyValue', 'setHierarchicalPropertyValues', 'setName', 'setPropertyValue', 'setPropertyValues', 'supportsService']

    # get handle on OpenOffice registry (read/write)
    def updatemgr(self, ctx, registry="/vnd.include-once.pagetranslate.Options/Leaves/Flags"):
            nodepath = PropertyValue(Name="nodepath", Value=registry)
            config = ctx.ServiceManager.createInstanceWithContext("", ctx)
            return config.createInstanceWithArguments("", (nodepath,))

    # read/store config dict
    def read(self):
            return dict((name, self.access.getByName(name)) for name in self.access.getElementNames())
            return {}
    def write(self, cfg):
        for name, value in cfg.items():
            if self.access.hasByName(name):
                self.access.setPropertyValue(name, value)

    # invoked on dialog initialization or for saving
    def callHandlerMethod(self, window=".UnoDialogControl", action="initialize|ok|back", name="external_event"):
        log.debug("OptonsPageTranslate:settings.callHandlerMethod({}, {}, {})".format(repr(window), action, name))
            params =
            # iterate over all dialog controls by name, and assign from/to config dict
            for name, cntrl in [(c.Model.Name, c) for c in window.getControls()]:
                if name in self.btn_map:
                    cntrl.addActionListener(action_listener(lambda *x: os.system(self.btn_map[name])))
                elif action == "initialize":
                    self.setControlValue(cntrl, params.get(name))
                elif action == "ok":
                    params[name] = self.getControlValue(cntrl)
            if action == "ok":
        return True
    # deal with CheckBox/TextEdit control differences
    def getControlValue(self, c):
        if hasattr(c, "State"): return int(1 if c.State else 0)
        elif hasattr(c, "Text"): return str(c.Text)
        elif hasattr(c, "getSelectedItem"): return str(c.getSelectedItem())
    def setControlValue(self, c, value):
        if hasattr(c, "State"): c.State = int(value if value else 0)
        elif hasattr(c, "Text"): c.Text = str(value if value else "")
        elif hasattr(c, "selectItem"): c.selectItem(str(value if value else ""), True)
        #else: log.debug([c, dir(c)])
    # XContainerWindowEventHandler
    def getSupportedMethodNames(self): return ("external_event",)
    # XServiceInfo
    def supportsService(self, name): return (name == self.impl_id)
    def getImplementationName(self): return self.impl_id
    def getSupportedServiceNames(self): return (self.impl_id,)
    def getServiceNames(self): return (self.impl_id,)

# register with LibreOffice
g_ImplementationHelper = unohelper.ImplementationHelper()
g_ImplementationHelper.addImplementation( pagetranslate, "org.openoffice.comp.pyuno.pagetranslate", ("",), )
g_ImplementationHelper.addImplementation( settings, settings.impl_id, () )