LibreOffice WriterのファイルをPython+DeepLで機械翻訳する 【Windows編】

MS Officeのファイルをプログラム的に操作する方法はいくつか紹介してきました。

CATOVIS Officeのようにテキストを抽出するものや、Excelの用語集を用いてWordファイルを一括置換するマクロ、PowerPointのフォントを一括で変換するマクロなどがあります。

単に情報を読み取るだけであれば、前者のようにXMLファイルにアクセスすれば良いのですが、書き換えるとなるとなかなか大変です。その場合は後ろの2つのようにマクロを使うか、PowerShellでCOMオブジェクトを作って操作するのがわかりやすいでしょう。

しかしこれはあくまでもWindowsで、MS Officeを所有しているからこそ使えるもの。

MacやLinuxで作業しているときや、Officeを購入していないPCでは動かすことができません。

そこで目をつけたのが、無料互換Officeの筆頭格 LibreOffice です。

なんとLibreOfficeはPythonでマクロを書くことができるほか、COMオブジェクトのようなUNOオブジェクトを介することで、外から動かすこともできるのだとか。しかもクロスプラットフォームなので、MacやLinuxにもインストールできます。これはやってみるしかありませんね。

ただし、LibreOffice+Pythonの情報量は圧倒的に少なく、また内容も古い物(OpenOffice時代のものとか)も多いため、かなりの試行錯誤を重ねることに……

ここではまず、Wordの代替であるWriterをPythonでシェルから動かし、DeepLの機械翻訳で書き換えるまでの流れを、備忘録を兼ねて書きたいと思います。

参考サイト

【Qiita】

LibreOffice を Python で操作する

LibreOffice を Python で操作: 入出力フィルタ

LibreOffice Pythonマクロ サンプルプログラム(初級者向け)

【Github】

unoserver

【その他】

T.B.P.日本語版 0: シリーズ「あるオープンソースオフィススイートを活用する」の目次

LibreOfficeを外部接続可能な状態で起動

まずはLibreOfficeを起動するのですが、このときに外部からの接続を受け付ける形にしなければなりません。

これは様々な記事に記載のあるとおり、–accept を引数として渡してあげる必要があります。また、ほとんどの記事はLinuxが前提になっているせいか、sofficelibreoffice コマンドで起動していますが、Windowsの場合、LibreOfficeのexeファイルにPATHが通っていないため、このコマンドを入力しても起動できません。そのため、まずはLibreOfficeがインストールされているフォルダを特定します。

私の環境では、C:\Program Files\LibreOffice\program\soffice.exe にありました。まずはこれがPowerShellから起動できるか確認します。Program Filesフォルダは間にスペースが入っているため、&をつけて文字列として実行します(タブを押せば自動補完してくれます)。

& 'C:\Program Files\LibreOffice\program\soffice.exe'

これでEnterキーを押せば、起動画面が立ち上がります。

このままでは通常の起動と同じで、外部から操作することができないため、いったん閉じた後今度は –accept 以下を引数として入力します。

& 'C:\Program Files\LibreOffice\program\soffice.exe' --accept="socket,host=localhost,port=2002,tcpNoDelay=1;urp;"

他のサイトでは –headless も引数として入れるように書かれていますが、最初は普通に起動して動作を確認しながら進めるのが良いと思います。コードが完成したら、–headless をつけて高速化します。

開発環境の準備

LibreOfficeの起動が確認できたら、次に開発環境の準備に取り掛かります。

今回はPythonインタプリタの指定のしやすさや、デバッグのやりやすさ等を考えて、PyCharm Community Editionを使用しました。

unoserver のREADMEにもあるとおり、このPythonスクリプトは「LibreOffice同梱(または指定)のPythonインタプリタを使用する」必要があります。

これはunoライブラリの依存を解決したり、操作に必要な pythonscript.py モジュールを読み込むためです。

そのため、最初にプロジェクト用のインタプリタを設定しておきます。インタプリタは soffice.exe と同じく、C:\Program Files\LibreOffice\program\ にあると思います。

最初は表示されていないため、歯車マークから追加してやる必要があります。

また、実行についても普通にインストールしたpythonではなく、LibreOfficeのpythonで実行する必要があるため、

& 'C:\Program Files\LibreOffice\program\python.exe' mycode.py

とすることにも注意ですね(python mycode.py では動きません)。

さぁ、これで開発の準備が整いました!

Pythonから接続する

それではいよいよ、Pythonのコードを書いていきたいと思います。

import uno
from pythonscript import ScriptContext


class LoServer:
    UNO_RESOLVER = 'com.sun.star.bridge.UnoUrlResolver'
    UNO_DESKTOP = 'com.sun.star.frame.Desktop'
    PRESET_DIRS = ['00-REF', '01-ORIGINAL', '02-MAP', '03-TRANSLATION', '04-CHECK', '05-DONE']

    def __init__(self, host='localhost', port='2002', named_pipe=None):
        self.host = host
        self.port = port
        self.named_pipe = named_pipe
        self.script = None

    def connect_to_libreoffice(self):
        local_ctx = uno.getComponentContext()
        local_smgr = local_ctx.ServiceManager
        resolver = local_smgr.createInstanceWithContext(LoServer.UNO_RESOLVER, local_ctx)
        if self.named_pipe is None:
            uno_string = f'uno:socket,host={self.host},port={self.port};urp;StarOffice.ComponentContext'
        else:
            uno_string = f'uno:pipe,name={self.named_pipe};urp;StarOffice.ComponentContext'
        ctx = resolver.resolve(uno_string)
        smgr = ctx.ServiceManager
        script_context = ScriptContext(ctx,
                                       smgr.createInstanceWithContext(LoServer.UNO_DESKTOP, ctx),
                                       None)

        self.script = script_context

if __name__ == '__main__':
    lo = LoServer()
    lo.connect_to_libreoffice()

このようにResolverを使うことで、acceptで待機させておいたLibreOfficeと接続をすることができます。

当然ながら、LibreOfficeに引数として渡したhost名とport番号と一致させてください。

これで接続までは進んだので、次にファイルを開いて選択範囲を移動させることを考えてみます。

ファイルを開く

unoserver のコードを参考にしながら、desktopオブジェクトを取得してファイルを開きます。

desktop = lo.script.getDesktop()
url = f'file:///{path_to_file}'
document = desktop.loadComponentFromURL(url, '_blank', 0, ())

ここで注意が必要なのが、loadComponentFromURL()メソッドは、引数としてファイルパスではなくURLをとるということ。そのため、ファイルパスを 「file://」という形式に書き換えたうえで呼び出しています。

このコードを実行すると、Writerが立ち上がってファイルが開かれます。

次に内容の取得ですが、ここからがなかなか苦戦しました……APIリファレンスが全然機能していないため、C:\Program Files\LibreOffice\share\Scripts\python へと進み、組み込みのサンプルマクロである InsertText.pyの記述を確認してみます。

# Example python script for the scripting framework

#
# This file is part of the LibreOffice project.
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This file incorporates work covered by the following license notice:
#
#   Licensed to the Apache Software Foundation (ASF) under one or more
#   contributor license agreements. See the NOTICE file distributed
#   with this work for additional information regarding copyright
#   ownership. The ASF licenses this file to you under the Apache
#   License, Version 2.0 (the "License"); you may not use this file
#   except in compliance with the License. You may obtain a copy of
#   the License at http://www.apache.org/licenses/LICENSE-2.0 .
#

def InsertText(text):
    """Inserts the argument string into the current document.
    If there is a selection, the selection is replaced by it.
    """

    # Get the doc from the scripting context which is made available to
    # all scripts.
    desktop = XSCRIPTCONTEXT.getDesktop()
    model = desktop.getCurrentComponent()

    # Check whether there's already an opened document.
    if not hasattr(model, "Text"):
        return

    # The context variable is of type XScriptContext and is available to
    # all BeanShell scripts executed by the Script Framework
    xModel = XSCRIPTCONTEXT.getDocument()

    # The writer controller impl supports the css.view.XSelectionSupplier
    # interface.
    xSelectionSupplier = xModel.getCurrentController()

    # See section 7.5.1 of developers' guide
    xIndexAccess = xSelectionSupplier.getSelection()
    count = xIndexAccess.getCount()

    if count >= 1:  # ie we have a selection
        i = 0

    while i < count:
        xTextRange = xIndexAccess.getByIndex(i)
        theString = xTextRange.getString()

        if not len(theString):
            # Nothing really selected, just insert.
            xText = xTextRange.getText()
            xWordCursor = xText.createTextCursorByRange(xTextRange)
            xWordCursor.setString(text)
            xSelectionSupplier.select(xWordCursor)
        else:
            # Replace the selection.
            xTextRange.setString(text)
            xSelectionSupplier.select(xTextRange)

        i += 1

まず、xSelectionSupplier = xModel.getCurrentController() の部分で xSelectionSuplier と呼ばれるものをつくっていますね。このインスタンスの getSelection() メソッドを呼び出すことで選択範囲が取得できそうです。

その後、xTextRange → xText → xWordCursor というようにインスタンスを作っていき、最後にsetString() メソッドを呼べば中身を変更できそうですね。

そしてもう一度、xWordCursorxSelectionSuplierselect() メソッドの引数に渡すことで、選択範囲を更新できるということでしょう。

以上を踏まえ、段落ごとにテキストを取得してDeepLのAPIに渡し、戻って来た訳文をセットするコードをつくってみました(DeepLには公式のPythonライブラリがありますが、ここではLibreOffice同梱のPythonから呼び出すことを考え、標準モジュールであるurllibを使用しています)。

また、ファイル終わりの判定が分からなかったので、最初に文章の最後に印をつけています。

import json
import LoServer
from urllib import request, parse

class WriterMt:
    def __init__(self, key):
        self.lo = LoServer()
        self.lo.connect_to_libreoffice()
        # DeepL 無料版のAPIキーをセット
        self.endpoint = f'https://api-free.deepl.com/v2/translate?auth_key={key}'

    def open_and_mt(self, path_to_file):
        # ファイルを開く
        desktop = self.lo.script.getDesktop()
        url = f'file:///{path_to_file}'
        document = desktop.loadComponentFromURL(url, '_blank', 0, ())

        # インスタンスの準備
        selector = document.getCurrentController()
        selection = selector.getSelection()
        text_range = selection.getByIndex(0)
        cursor = text_range.getText().createTextCursorByRange(text_range)
        current_text = ''

        # ファイル終わりの目印
        END_MARK = '_@@_EOF_@@_'
        content = document.Text
        end = content.End
        end.String = END_MARK

        # 段落ごとに選択
        while current_text != END_MARK:
            if cursor.isEndOfParagraph():
                pass
            else:
                # goto~系のメソッドは、Trueを渡すと選択範囲を拡張、Falseを渡すと選択範囲を消去して移動する
                cursor.gotoEndOfParagraph(True)
                current_text = cursor.getString()
                if current_text == '':
                    pass
                elif current_text == END_MARK:
                    cursor.setString('')
                else:
                    mt = get_deepl(current_text)
                    if mt != '':
                        cursor.setString(mt)

                    # 次の段落へ移動する
                    cursor.gotoEndOfParagraph(False)
                    cursor.goRight(1, False)
                    selector.select(cursor)

        # 最後にファイルを上書きし、操作権を放棄する
        document.store()
        document.dispose()

    def get_deepl(text):
        headers = {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
        params = {
            'text': text,
            'source_lang': 'JA',
            'target_lang': 'EN'
        }
        req = request.Request(self.endpoint, parse.urlencode(params).encode(), headers)
        try:
            with request.urlopen(req) as res:
                if res.status == 200:
                   data = json.loads(res.read().decode('utf-8'))
                   mt = data['translations'][0]['text'].strip()
                   return mt
                else:
                   return ''
            except:
                print(f'DeepL got an error')
                return ''

if __name__ == '__main__':
    writer = WriterMt('xxxxxxxxxxxxxxxxxxxxxxxxxxxx')
    writer.open_and_mt(path_to_file)
    print('MT Done!')
    

あとはこの WriterMt.py を実行するだけです。

くどいようですが、LibreOffice同梱のPythonで実行することをお忘れなく!

LibreOfficeのWriterは、専用のodtフォーマットだけでなく、MS Wordの doc/docxファイルも同じように開いて操作、保存ができます。

DeepLは有料プランでも翻訳できるファイル数が限られているため、大量の機械翻訳が必要な場合、このプログラムを試してみてください!

CATOVIS LS

MS Office Wordと接続。軽量型でWYSIWYGを実現