Ruby+PyCall.rbでLibreOffice Calcのオートメーションをやってみた（Ubuntu 18.04）

これは [LibreOffice Advent Calendar 2020](https://adventar.org/calendars/5258) の20日目の記事です。

前の記事は nogajun さんの「[XMLを表で編集したりCSVに書き出したいときはLibreoffice Calcの「XMLソース」を使えば一撃です](http://www.nofuture.tv/diary/20201219.html#p01)」です。


----

<!--
PyCall というものがあります。
これは Ruby から Python を使うというものです。
Ruby + PyCall で LibreOffice を操作するの、誰かもうやってるかなと思ってググってみたのですが、
まだ誰もやってないようだったので軽く試してみました。
ブルーオーシャンですね！

「Ruby PyCall LibreOffice」でググってみたところ、まだ誰もやっていないようだったので試してみました。
-->

PyCall.rb は Ruby から Python を利用するためのブリッジライブラリです。詳しくは下記を参照してください。 

- [mrkn/pycall.rb: Calling Python functions from the Ruby language](https://github.com/mrkn/pycall.rb)
- [(2017) Ruby-Pythonブリッジライブラリ「PyCall」を使ってRubyでデータ分析をしよう！ (1/3)：CodeZine（コードジン）](https://codezine.jp/article/detail/10251)
  - PyCall.rb 作者の mrkn さんによる解説記事

「Ruby PyCall LibreOffice」でググってみたところ、まだ事例がないようだったので試してみました。





# 準備

まっさらな環境で試したいので Docker を使います。
素の Ubuntu 18.04 でも大体同じだと思いますが、適宜 `apt 〜` を `sudo apt 〜` に読み替えるなどしてください。

<!--
Docker を使って何かすることが目的ではありませんので、Docker が不要であれば普通に Ubuntu でやればよいと思います。

// 再現性の担保・確保のため
-->


----

雑な Dockerfile。

```dockerfile
FROM ubuntu:18.04

RUN apt-get update
RUN apt-get -y install libreoffice-calc
RUN apt-get -y install ruby
RUN apt-get -y install build-essential

WORKDIR /root/work
```

イメージ作成＋コンテナ起動。以降はファイルの編集を除いてコンテナ内での作業です。

```bash
docker build -t libo_pycall:trial .
docker run --rm -it -v "$(pwd):/root/work/" libo_pycall:trial bash
```

バージョンを確認。

```
root@2377c5b80dfb:~/work# python3 -V
Python 3.6.9
root@2377c5b80dfb:~/work# ruby -v
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux-gnu]
```

今回は pyenv, rbenv などは使わず、素の Python/Ruby でやってみます。




<!-- ■■■■■■■■ -->

# まずは Python で書いてみる

Ruby + PyCall を試す前に、まずは Python でサンプルを書いて動かしてみます。ちなみに私は Python 詳しくなくて、見様見真似でやっています。ひょっとしたらおかしなことをやっている部分があるかもしれません。

下記のような操作をやってみます。

1. sample.ods ファイルを開く
2. `Sheet1` シートの `A1` セルに入っている数を読んで
3. それに 1 足した数で `A1` セルを更新
4. ファイルを保存
5. ファイルを閉じる

```python
# sample.py

import uno

def get_desktop():
  local_ctx = uno.getComponentContext()
  resolver = local_ctx.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", local_ctx)
  ctx = resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
  return ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)

def open_ods_file(path):
  desktop = get_desktop()
  url = uno.systemPathToFileUrl(path)
  return desktop.loadComponentFromURL(url, "_blank", 0, ())

doc = open_ods_file("/root/work/sample.ods")
sheet = doc.Sheets.getByName("Sheet1")

cell = sheet.getCellByPosition(0, 0)
n = int(cell.getFormula())
print(n)
cell.setFormula(int(n) + 1)

doc.store()
doc.dispose()
```

LibreOffice インスタンスを起動（最初に一度だけ実行）

```sh
soffice --headless "--accept=socket,host=localhost,port=2002;urp;" &
```

sample.py を実行

```sh
python3 sample.py
```

sample.py を複数回実行し、実行するたびに数値が1ずつ増えていけば成功です。

<!--

 ----

ちなみに、 `import uno` すると `/usr/lib/libreoffice/program` がライブラリの探索パスに追加されるようです。

 ```sh
root@fac43278ae75:~/work# python3
Python 3.6.9 (default, Oct  8 2020, 12:12:24) 
[GCC 8.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.path
['', '/usr/lib/python36.zip', '/usr/lib/python3.6', '/usr/lib/python3.6/lib-dynload', '/usr/local/lib/python3.6/dist-packages', '/usr/lib/python3/dist-packages']
>>> import uno
>>> sys.path
['', '/usr/lib/python36.zip', '/usr/lib/python3.6', '/usr/lib/python3.6/lib-dynload', '/usr/local/lib/python3.6/dist-packages', '/usr/lib/python3/dist-packages', '/usr/lib/libreoffice/program']
 ```

-->


<!-- ■■■■■■■■ -->

# PyCall のインストール

<!--
ではこれを Ruby + PyCall に移植します。

何はともあれ PyCall をインストール。
-->

とりあえず gem install。

```
gem install --pre pycall
```

失敗しました。

```
Fetching: pycall-1.3.1.gem (100%)
Building native extensions. This could take a while...
ERROR:  Error installing pycall:
        ERROR: Failed to build gem native extension.

    current directory: /var/lib/gems/2.5.0/gems/pycall-1.3.1/ext/pycall
/usr/bin/ruby2.5 -r ./siteconf20201217-110-g260nd.rb extconf.rb
mkmf.rb can't find header files for ruby at /usr/lib/ruby/include/ruby.h

extconf failed, exit code 1

Gem files will remain installed in /var/lib/gems/2.5.0/gems/pycall-1.3.1 for inspection.
Results logged to /var/lib/gems/2.5.0/extensions/x86_64-linux/2.5.0/pycall-1.3.1/gem_make.out
```

ruby.h が見つからないと言われました。 `ruby-dev` をインストールしてやりなおし。

```
apt install ruby-dev
gem install --pre pycall
```

インストール成功。

```
root@fac43278ae75:~/work# ruby -r pycall -e 'p PyCall::VERSION'
"1.3.1"
```


<!-- ■■■■■■■■ -->

# 軽く確認

[pycall.rb の README](https://github.com/mrkn/pycall.rb/blob/master/README.md) に載っているコード例で軽く確認してみます。

```sh
root@2377c5b80dfb:~/work# irb --prompt simple
>> require 'pycall/import'
=> true
>> include PyCall::Import
=> Object
>> pyimport :math
Traceback (most recent call last):
  File "/var/lib/gems/2.5.0/gems/pycall-1.3.1/lib/pycall/python/investigator.py", line 1, in <module>
    from distutils.sysconfig import get_config_var
ModuleNotFoundError: No module named 'distutils.sysconfig'
Traceback (most recent call last):
        8: from /usr/bin/irb:11:in `<main>'
        7: from (irb):3
        6: from /var/lib/gems/2.5.0/gems/pycall-1.3.1/lib/pycall/import.rb:18:in `pyimport'
        5: from /var/lib/gems/2.5.0/gems/pycall-1.3.1/lib/pycall.rb:62:in `import_module'
        4: from /var/lib/gems/2.5.0/gems/pycall-1.3.1/lib/pycall/init.rb:16:in `const_missing'
        3: from /var/lib/gems/2.5.0/gems/pycall-1.3.1/lib/pycall/init.rb:35:in `init'
        2: from /var/lib/gems/2.5.0/gems/pycall-1.3.1/lib/pycall/libpython/finder.rb:41:in `find_libpython'
        1: from /var/lib/gems/2.5.0/gems/pycall-1.3.1/lib/pycall/libpython/finder.rb:36:in `find_python_config'
PyCall::PythonNotFound (PyCall::PythonNotFound)
```

`pyimport` でエラーになりました。
`python3-distutils` をインストールしてやりなおし。

```
apt install python3-distutils
```

今度は成功しました。

```sh
root@2377c5b80dfb:~/work# irb --prompt simple
>> require 'pycall/import'
=> true
>> include PyCall::Import
=> Object
>> pyimport :math
=> :math
>> math.sin(math.pi / 4) - Math.sin(Math::PI / 4)           
=> 0.0
>> 
```




<!-- ■■■■■■■■ -->

# サンプルスクリプトを Ruby に移植

では Python で書いたスクリプトを Ruby で書き直しましょう。

```ruby
# sample.rb

require "pycall/import"
include PyCall::Import

pyimport "uno"

def get_desktop
  local_ctx = uno.getComponentContext()
  resolver = local_ctx.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", local_ctx)
  ctx = resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
  ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
end

def open_ods_file(path)
  desktop = get_desktop()
  url = uno.systemPathToFileUrl(path)
  desktop.loadComponentFromURL(url, "_blank", 0, [])
end

doc = open_ods_file("/root/work/sample.ods")
sheet = doc.Sheets.getByName("Sheet1")

cell = sheet.getCellByPosition(0, 0)
n = cell.getFormula().to_i
puts n
cell.setFormula(n + 1)

doc.store()
doc.dispose()
```

Python 版では `desktop.loadComponentFromURL(url, "_blank", 0, ())` のように3つ目の引数として空のタプルを渡していましたが、ここは空配列にしました。それ以外は Python 版とほとんど同じです。

参考: [LibreOffice: XComponentLoader Interface Reference](https://api.libreoffice.org/docs/idl/ref/interfacecom_1_1sun_1_1star_1_1frame_1_1XComponentLoader.html)


実行。

```
ruby sample.rb
```

Python 版と同じように、実行するたびに 1 増える動きになりました。大丈夫そうです。




<!-- ■■■■■■■■ -->

# サンプルその2

もう一つ別の例ということで、シート `Sheet2` の内容

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234055/4df1280f-31eb-8345-30e4-253a6d99fcd2.png)

を標準出力にダンプするスクリプトを書いてみました。

```ruby
# sample2.rb

require "json"

require "pycall/import"
include PyCall::Import

pyimport "uno"

def get_desktop
  local_ctx = uno.getComponentContext()
  resolver = local_ctx.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", local_ctx)
  ctx = resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
  ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
end

def open_ods_file(path)
  desktop = get_desktop()
  url = uno.systemPathToFileUrl(path)
  desktop.loadComponentFromURL(url, "_blank", 0, [])
end

def all_select_cursor(sheet)
  range = sheet.getCellRangeByName("A1")
  cursor = sheet.createCursorByRange(range)
  cursor.gotoEndOfUsedArea(true)
  cursor
end

def get_col_index_max(sheet)
  all_select_cursor(sheet).Columns.Count - 1
end

def get_row_index_max(sheet)
  all_select_cursor(sheet).Rows.Count - 1
end

doc = open_ods_file("/root/work/sample.ods")
sheet = doc.Sheets.getByName("Sheet2")

(0..get_row_index_max(sheet)).each do |ri|
  cols = 
    (0..get_col_index_max(sheet)).to_a.map do |ci|
      cell = sheet.getCellByPosition(ci, ri)
      cell.getFormula()
    end
  puts JSON.generate(cols)
end

doc.dispose()
```

実行。

```sh
root@fac43278ae75:~/work# ruby sample2.rb 
func=xmlSecCheckVersionExt:file=xmlsec.c:line=188:obj=unknown:subj=unknown:error=19:invalid version:mode=abi compatible;expected minor version=2;real minor version=2;expected subminor version=25;real subminor version=26
["id","name","score","note"]
["1","foo","12.3","hoge"]
["2","bar","-12.3",""]
```

ヨシ！



<!-- ■■■■■■■■■■■■■■■■ -->
# おわりに

というわけで、インストールまわりでちょっとだけひっかかりましたが、簡単なものであればすんなり動かせると分かりました。
PyCall.rb すばらしいですね……。


----

LibreOffice Advent Calendar 2020 向けに下記の記事も書きました。あわせてどうぞ。

- [LibreOffice BasicでLispインタプリタ（mal）を書いた](https://qiita.com/sonota88/items/501f0efa437bc4d53cc7)
- [素朴な自作言語のコンパイラをLibreOffice Basicに移植した](https://qiita.com/sonota88/items/31c127c42a9722892aa8)




<!-- ■■■■■■■■■■■■■■■■ -->
# この記事を読んだ人は（ひょっとしたら）こちらも読んでいます

<!-- JRubyでLibreOffice Calcのfodsファイルを読み書きするサンプル 2021 -->
https://qiita.com/sonota88/items/b88c89d763cf872db5a1

JRuby + Java版SDK の組み合わせも良い選択肢だと思います。

---

<!-- Ruby + PyCall でデータサイエンス100本ノック（構造化データ加工編）をやってみた -->
https://qiita.com/sonota88/items/52d0e35dc5e6e112c02f