情弱がLibreOfficeをいじってみた

本記事は、東京大学工学部電子情報工学科で開講されている実験:大規模ソフトウェアを手探るで、オープンソースソフトウェアであるLibreOfficeを色々と改造した際の苦労や実装方法などを書き連ねたものである。
これからLibreOffice(やオープンソース)を触ろうと思う人にもわかりやすい記事にした(つもりな)ので、ぜひ参考にしてもらいたい。

0.目次

  1. 概要
  2. LibreOfficeのビルド・デバッグ方法
  3. LibreOffice calcに変更を加える
  4. 総評

1.概要

LibreOfficeの詳しい説明については公式HPに譲るが、一言で言い表すならフリーのオフィスソフトである。オフィスソフトといえばMicrosoft Officeが有名だろうが、LibreOfficeは無料でオープンなソフトウェアを目指し、Linux上でも動作する。ソースコードももちろん全部公開されており、各自で触ることが許されている。
LibreOfficeには、表計算ができる「calc」(cf:Excel)や、文書作成ができる「writer」(cf:Word)などがあるが、今回、我々は表計算ソフトである「calc」に新しい演算子と関数を追加した。
また、実行環境は以下である。

以下の説明ではこの環境下における操作を書くので、他の環境を使っている人は適当に読み替えて欲しい。

注意

  • 本記事は冗長で、記述が簡潔とは言えない。我々のつまずいた点などを逐一記述したためである。多くの記事は「これを、こうしたら、できた」のような質素な記述が多く、つまずいたであろう点があまり書かれていない。そのため、本記事では冗長になることに目を瞑り、実際に「どのように手探ったか」を記述することを逐一意識し、知見の共有に努めた。
  • 実験レポートの体裁としてはあまり適切ではないかもしれないが、前述する「知見の共有」に重きを置いたという点をご了承頂きたい。
  • 記事中で「関数」という言葉を、calc上で認識される関数(COMBIN、AVERAGEなど)と、ソースコード中の関数(void ScCombin()など)という2つの意味で使っているので、混濁しないように注意して欲しい。

2.LibreOfficeのビルド・デバッグ方法

オープンソースを改造し、それを反映するためには当然ビルドをしなければならない。加えて、デバッグもできたほうがいいだろう。 ビルドやデバッグ方法は、ここに書かれている。残念ながら、英語記事しかないので、英語が分からない人は頑張るしかない。下記にビルドやデバッグをするための手順を記す。

Step1:ソースコードをとってくる

オープンソースを触るにあたって、まずはソースコードを落としてこなければ話が始まらない。基本的には、ここに書いてあるとおりにすれば、tarファイルやらgit cloneやらでソース一式を落としてくることができる(かなり重いが)。

Step2:必要なパッケージファイルを取得する

LibreOfficeソースコードを落としてきたし、あとはmakeすればいい!というわけではなく、LibreOfficeのmakeに必要なパッケージをインストールしなければならない。
まず、/etc/apt/source.listファイル内の、

#deb-src http://archive.ubuntu.com/ubuntu/ xenial main

コメントアウト(#)を外す。その後、

$ sudo apt-get update

を叩く。最後に、

$ sudo apt-get build-dep libreoffice

をすれば、LibreOfficeで必要な依存パッケージがすべてインストールされる。

Step3:makeする

makeの手順は次の通り。

$ ./autogen.sh --enable-dbgutil --without-java --without-help --without-myspell-dicts  
$ make build-nocheck

これで、makeが走るはずだ。./autgen.shはmakeの際の設定を色々としてくれる(?)シェルファイルで、これにオプションを渡すことで様々な状態でのmakeが可能になる。 たとえば、--enebale-dbgutilデバッグシンボルが設定される(と思う)。
また、makeの後のbuild-nocheckオプションはビルド後のテストを飛ばしてくれるオプションである。これがないと、make時に単体テストで落ちるときがあるので注意。もしかしたらmakeだけでもビルドできるかもしれないが、我々の実行環境では上手くいかないときがあったので、必ずbuild-nocheckオプションをつけていた。
なお、注意点として最初のmakeにはとても時間がかかるので、まとまった時間がとれるときにmakeをするようにしよう。

Step4:実行する

さて、Step3までで実行ファイルが生成されたはずである。これを確かめるためにも次のコマンドを叩く。

$ instdir/program/soffice --calc

core/instdir/program/フォルダの、soffice.binファイルが実行ファイル本体であり、これに--calc--writerなどのオプションを渡すことで特定のオフィスを開くことができる。デバッグ用に最適化を解除しているのでとても起動が遅いがそこは我慢する。

Step5:デバッグする

Step4ではただ実行しただけなので、デバッグはできない。コマンドラインgdbを起動するには、

$ make --debugrun

とすればいいが、「現代人が素のgdbデバッグするとかマジ?」というお気持ちなので、VS Codeを使ってデバッグできるようにしたい。 VS Codeでデバッグができるようにするには、launch.jsonを書き換えてやらなければならない。launch.jsonなんてファイル無いけど!という人は、Ctrl + Shift + Pで幸せになれると思う。下に、私のlaunch.jsonの一例を示す。

{
        "version": "0.2.0",
        "configurations": [
            {
                "name": "(gdb) Launch",
                "type": "cppdbg",
                "request": "launch",
                "program": "${workspaceRoot}/instdir/program/soffice.bin",
                "args": ["--calc"],
                "stopAtEntry": false,
                "cwd": "${workspaceRoot}",
                "environment": [],
                "externalConsole": true,
                "MIMode": "gdb",
                "setupCommands": [
                    {
                        "description": "Enable pretty-printing for gdb",
                        "text": "-enable-pretty-printing",
                        "ignoreFailures": true
                    }
                ]
            }
        ]
    }

launch.jsonの書き方についてはVS Codeの公式ページを見て欲しいが、最低限の説明をすると、"program"プロパティには実行するプログラムを渡し、"args"プロパティにはコマンドライン引数を書くことができる。
さて、これでVS CodeのGUIを使って直感的にデバッグができる(嬉しい)。ということで、後はデバッグを実行しよう。現時点では何もブレイクポイントを設定していないので、普通にcalcが起動する。

3.LibreOffice calcに変更を加える

2.でようやく下準備が整ったのでここから、calcをいじっていく。我々が、calcに加えた変更は以下の通りである。

  • 演算子の追加
    -組み合わせを計算する演算子「@」
    -論理積を計算する「&&」、論理和を計算する「||」

  • 関数の追加
    -任意のシェルコマンドを実行する「SHELL」関数

それぞれについて、どこをどう変更し、どのように変更すべき場所を探したのかを記す。なお、時系列としては、一番最初に組み合わせ演算子「@」を実装し、その後「SHELL」「&&」「||」の順で実装した。

演算子の追加

組み合わせ計算や論理積論理和を計算する関数は既に存在していた。そのため我々は、「演算子をcalcに認識させてその引数を既存の関数に投げる」という方法で実装できないかと考え、実際にその方法で実装をした。
上記を実装するにあたって、次の箇所を探さなければならなかった。

  • 入力した式(例:= 3 * 4 + 5)を個々のToken(例:= 3 * 4 5)に分解(Tokenize)する箇所。
  • 個々のTokenが何を表すか(例:*ならocMul+ならocAddなど)を対応付ける箇所。
  • 実際に計算を行う箇所。

それぞれの探し方としては、ファイルの文字列検索を行った。特に、"+"addなどの単語で調べれば演算子を読み取っているところが分かると思い、片っ端から検索をかけていった。 また、組み合わせ計算を行う関数自体は既存のものがあったのでその関数名COMBINで検索をかけた。その結果、様々なファイルがヒットしたが、sc/source/core/tool/interpr4.cxxに、次のような関数を見つけた。

StackVar ScInterpreter::Interpret() {
//省略
        switch(eOp) {
//省略
                case ocAdd              : ScAdd();                     break;
                case ocSub              : ScSub();                      break;
                case ocMul              : ScMul();                      break;
                case ocDiv               : ScDiv();                       break;
//省略
                case ocCombin          : ScCombin();                   break;
                case ocCombinA        : ScCombinA();                break;
                case ocPermut           : ScPermut();                    break;
//省略
        }
//省略
}

なんというか、いかにも個々のTokenについたタグごとに実際の計算を行っていそうである。ここが実際の計算時に読み込まれているか確かめるために、適当にブレイクポイントを設定して、calcに=1+2などと式を打ち込むと、確かにブレイクしたので、どうやら実際の計算時にはここが使われているらしいことが分かった。
しかも、この記述の仕方から、関数による計算も、演算子による計算も、同じように処理を行っているということまでわかった。我々が今知りたいのは、演算子の計算方法なので、組み合わせ計算をしていそうなScCombin()ではなく、試しにScAdd()がどんな関数か定義を見に行くと、これはsc/source/core/tool/interpr5.cxxに次のように定義されていた。

void ScInterpreter::ScAdd()
{
    CalculateAddSub(false);
}

void ScInterpreter::CalculateAddSub(bool _bSub)
{
    double fVal1 = 0.0, fVal2 = 0.0;
//省略
    if ( GetStackType() == svMatrix )
        pMat2 = GetMatrix();
    else
    {
        fVal2 = GetDouble();
//省略
    }
    if ( GetStackType() == svMatrix )
        pMat1 = GetMatrix();
    else
    {
        fVal1 = GetDouble();
//省略
    }
//省略
        if ( _bSub )
            PushDouble( ::rtl::math::approxSub( fVal1, fVal2 ) );
        else
            PushDouble( ::rtl::math::approxAdd( fVal1, fVal2 ) );
    }
}

かなり省略したので分かりにくいかもしれないが、まずScAdd()は足し算と引き算を両方やるCalculateAddSub()に飛ぶだけである。ではその中身を見てみると、fval1fval2GetDouble()とやらを呼び出すことで値を入れ、最後に、

PushDouble( ::rtl::math::approxAdd( fVal1, fVal2 ) )

とあり、足し算の結果を出力しているらしいということが分かった。ここで関数名についているPushという文字から、計算の際にはスタックを使ってごにょごにょやるんだなぁ、ということも分かった。細かいことは抜きにして、GetDouble()で値をとって来ることができて、PushDouble(result)で計算結果を出力できそうだ。
次に、我々が使いまわしたいと考えているScCombin()を見に行くと、これはsc/source/core/tool/interpr3.cxxに次のように記述されていた。

void ScInterpreter::ScCombin()
{
    if ( MustHaveParamCount( GetByte(), 2 ) )
    {
        double k = ::rtl::math::approxFloor(GetDouble());
        double n = ::rtl::math::approxFloor(GetDouble());
        if (k < 0.0 || n < 0.0 || k > n)
            PushIllegalArgument();
        else
            PushDouble(BinomKoeff(n, k));
    }
}

やはり、GetDouble()で値をとり、PushDouble()で計算結果を出力している。とりあえず、演算子による計算で使う数がそのまま関数に渡せるのか調べるため、interpr5.cxxに次のように書き変えた。

        case ocMul       :ScCombin()        break;

つまり、掛け算演算子*を使って、=9*2のようにしたときに9C2を計算してくれるようにしたわけである。しかし、これでは「引数がInvalidだよ」とcalcでエラーが出てしまった。どうやら、ScCombin()内の、

  if ( MustHaveParamCount( GetByte(), 2 ) )

で、関数の引数の数が足りないと怒られるらしく、演算子としてスタックに数字がプッシュされる場合は、引数の数が加算されないらしい。ということで、このifを外しただけの新しい関数ScCombinB()を同ファイル内に作り、

        case ocMul       :ScCombinB()        break;

と、こっちを読み出すようにすると上手く計算してくれた。
これで、既存の演算子の場合はそのまま関数を使って計算することができることが分かったので、後は、「@」演算子を認識してもらって、新しくタグocCombinBを作り、それらを対応付ければよい。さしあたっては、interpr5.cxxに次の行を追加した。

        case ocCombinB       :ScCombinB()        break;

このocCombinBは我々が新しく作ったタグである。当然、このままでは認識されないので、interpr5.cxxがincludeしているヘッダファイルや、ocCombinで検索をかけて、該当しそうな場所に片っ端からocCombinBのことを追加していった。その過程で、oc***は、SC_OPCODE_***というint型の定数に紐付けされていることが判明したので、

        ocAdd               = SC_OPCODE_ADD,
        ocSub               = SC_OPCODE_SUB,
        ocMul               = SC_OPCODE_MUL,
        ocDiv               = SC_OPCODE_DIV,
        ocAmpersand         = SC_OPCODE_AMPERSAND,
        ocPow               = SC_OPCODE_POW,
        ocCombinB          = SC_OPCODE_COMBIN_B,
//省略

のように対応付けも行った。
さあ、後は「@」演算子をどこかにあるであろう「演算子一覧」に追加すれば終わりだ。
と、これで上手くいくかと思いきや、ここでつまずいた。問題は2つだ。
1つは、「@」と「ocCombinB(SC_OPCODE_COMBIN_B)」を紐付けるファイルが全く見つからなかったこと。片っ端から、"+"ocAddSC_OPCODEで検索をかけても一向に出てこない。もしや存在していないのでは、と諦めかけていた。そのとき我々が探していたのは.hxxファイルや.cxxファイルだった。ふと全ての拡張子について調べてみると、次のような中身のファイルが見つかった。

//省略
    { "+" , SC_OPCODE_ADD },
    { "-" , SC_OPCODE_SUB },
    { "*" , SC_OPCODE_MUL },
    { "/" , SC_OPCODE_DIV },
    { "&" , SC_OPCODE_AMPERSAND },
    { "^" , SC_OPCODE_POW },
//省略

いや、絶対こいつじゃん。で、何故こいつが見つからなかったというと、こいつのファイル名がformula/inc/core_resource.hrc。いや、.hrcファイルってなんやねん。調べてみてもよく分からないレベルでよく分からないファイルでしたが、ようやく演算子を対応付ける部分が見つかったので、ここの次のような行を追加した。

    { "@" , SC_OPCODE_COMBIN_B },

やったー!これでいけるぞ!と思ったものの、未だにエラーが消えない。何でや、とScCombin()からコールスタックで上へ上へと遡っていくと、formula/source/core/api/FormulaCompiler.cxxというファイルにたどり着いた。どうやら、この中でTokenへのタグ付けやっているらしい。この中でocCombinBが2項演算子として認識されていなかったために、エラーが生じていたのだ。これが2つ目の問題である。
ブレイクポイントを仕掛けながら逐次実行してみると、この中にあるNextToken()という関数がTokenを読み取ってタグ付けする本体らしい。また、2項演算子として扱うために、次のようにocCombinBを追加した。

void FormulaCompiler::PowLine()
{
    PostOpLine();
    while (mpToken->GetOpCode() == ocPow || mpToken->GetOpCode() == ocCombinB || mpToken->GetOpCode() == ocLogicalAnd || mpToken->GetOpCode() == ocLogicalOr)
    {
        FormulaTokenRef p = mpToken;
        NextToken();
        PostOpLine();
        PutCode(p);
    }
}

また、ヘッダファイルで、

/*** Binary operators ***/
#define SC_OPCODE_START_BIN_OP       50
#define SC_OPCODE_ADD                50
#define SC_OPCODE_SUB                51
#define SC_OPCODE_MUL                52
#define SC_OPCODE_DIV                53
#define SC_OPCODE_AMPERSAND          54
#define SC_OPCODE_POW                55
#define SC_OPCODE_EQUAL              56
#define SC_OPCODE_NOT_EQUAL          57
#define SC_OPCODE_LESS               58
#define SC_OPCODE_GREATER            59
#define SC_OPCODE_LESS_EQUAL         60
#define SC_OPCODE_GREATER_EQUAL      61
#define SC_OPCODE_AND                62
#define SC_OPCODE_OR                 63
#define SC_OPCODE_INTERSECT          64
#define SC_OPCODE_UNION              65
#define SC_OPCODE_RANGE              66
#define SC_OPCODE_STOP_BIN_OP        67

のように2項演算子SC_OPCODEの範囲が指定されていた。そこで、最後の数行を次のように書き換えた。

#define SC_OPCODE_RANGE              66
#define SC_OPCODE_COMBIN_B           67
#define SC_OPCODE_STOP_BIN_OP        68

SC_OPCODE_START_BIN_OPSC_OPCODE_STOP_BIN_OPの間にある数字を持つ演算子が2項演算子とみなされているようだった。上記の追加により無事にocCombinBが2項演算子であるとみなしてもらえた。
ここまで長かったが、これによりようやく「@」マークを使って組み合わせの結果をセルに表示することができた。
論理積「&&」、論理和「||」の実装は、組み合わせのときと同様に実装することができた。ただ、2文字の演算子の認識が難しかったようなので、詳しくは実装担当者であるすぎやんのブログを見て欲しい。

関数の追加

関数の追加も、基本的には演算子のときと同じである。なぜなら、演算子の計算も、関数の計算も、同様の処理で行われていることが分かったからだ。ただ、演算子のときと違うのは、今回は新しい関数を一から実装しなくてはならないということだ。SHELL関数は、「引数としてコマンド(文字列)を1つ受け取り、シェルで実行後その標準出力(文字列)をセルに出力する」。つまり、新しく実装するにあたって、「1つの文字列を受け取り1つの文字列を返す関数」を参考にすればいいわけだ。我々が参考にしたのは、UPPER関数である。これは、sc/source/core/tool/interpr1.cxx内でScUpper()として定義されており、入力した英字列を全て大文字にして返してくれる、つまり1つの文字列を受け取り、加工して1つの文字列を返してくれるという、参考にするのにぴったりな関数である。その定義は次の通り。

void ScInterpreter::ScUpper()
{
    OUString aString = ScGlobal::pCharClass->uppercase(GetString().getString());
    PushString(aString);
}

シンプル is 最高。我々もこれと全く同じようにすればいい。
まず考えなくてはいけなかったのは、OUStringという型が何者かということだった。検索をかけてみると、どうやら文字列を便利に扱えるメソッドやらを持っているclassの1種らしい。LibreOffice内では文字列はOUStringというオブジェクトで扱われている。リンクの情報を見ていると、OUStringとString(char配列)を変換するような方法もあったため、それを元にして次のように関数ScShell()を実装した。

void ScInterpreter::ScShell()
{
    OUString aString = GetString().getString();
    OString o = OUStringToOString( aString, RTL_TEXTENCODING_ASCII_US );
    char *cmd = o.pData->buffer;
    char buf[256];
    OUString eString = OUString::createFromAscii("");
    FILE *fp;
    if ((fp = popen(cmd,"r")) == NULL) {
      printf("Error!\n");
    }
    while (fgets(buf, 256, fp) != NULL) {
      OUString bufString = OUString::createFromAscii(buf);
      eString = eString.concat(bufString);
    }

    pclose(fp);

  PushString(eString);
}

この関数内で行っていることはシンプルで、

  • セルに入力された値をOUString型で取得し
  • それをchar配列cmdに格納し
  • popenでシェルにcmdを渡し、実行
  • 実行結果をfgetsbufに格納し、OUString形式に変換し
  • PushString()でセルに出力(正確にはスタックに吐き出す)

しただけである。
これに、「演算子の追加」の際に追加したときと同様に、諸々のヘッダファイルを書き換えることで、実装が完了した。

4.総評

本実験は自由度が高く、極めて実践的であった。OSSを触るという経験は、自分の力になったと強く感じている。本記事が少しでもLibreOfficeを始めとするオープンソースを触る人の参考になれば幸いである。