LibreOffice Drawのodgファイルから図形の情報を抜き出して使う

これは LibreOffice Advent Calendar 2019 の 3日目の記事です!

TL;DR

  • プログラムに入力として与えるデータの編集をどうするか問題
  • 位置情報などはテキストで管理すると直感的に修正できなくて辛い
  • LibreOffice Draw で編集して odg ファイルから情報を抜き出して使う方法を試してみた

動機

  • プログラムに入力として与えるデータを用意したい
    • ゲームのマップ、オブジェクトの配置など
    • アルゴリズムや分析処理、作図ツールの検証に使うデータ
    • etc.
  • ちょっとしたものならプログラム内に直接書いたりテキストデータとして用意したり
  • 「ちょっとした」で済まなくなってくると辛い
    • 位置情報
    • 構造が複雑
    • データが多い
  • どう辛いか

    • 直感的に編集できない
      • 一度2Dのグラフィックに変換しないと何がどうなっているのか分からない
        • 配置、要素同士の位置関係、サイズ、オブジェクトの種類、属性、etc.
      • 編集→表示させて確認→編集… を繰り返さないといけなくて手数が増えてめんどくさい
  • こういう場合、WYSIWYG なエディタが欲しくなる

    • 出来合いのツールがあればそれを使えばいいが、ない場合は……
    • 自作する?
    • GUI自作は大変
      • コピペ、D&D、アンドゥ/リドゥ
    • 大変なので諦めてがんばりがち
      • 適当な可視化ツールだけ作ってお茶を濁したりしがち
    • エディタがあれば作業効率上がるはずなのに……コストが見合わない
    • 特にすばやくプロトタイプを作りたい場合、手間をかけずにサッと使いたい

そこで、LibreOffice Draw を汎用エディタとして使えないか? と考えました。

矩形

さっそくやってみましょう。 まずは基本ということで、矩形の位置とサイズを odg ファイルから抜き出してみます。

※ odg ファイルと書いてますが、以下では Flat XML な fodg ファイルを使います。 odg でもだいたい同じだと思います。

Draw でこんな図形を描きます。

f:id:sonota88:20191201055353p:plain

fodgファイルの大まかな構造はこうなっています。

<office:document>
  <!-- メタデータ、スタイルの設定など -->
  <office:body>
    <office:drawing>
      <draw:page draw:name="page1" ... >
        ここに図形の記述が並ぶ
      </draw:page>
      <draw:page draw:name="page2" ... >
        ここに図形の記述が並ぶ
      </draw:page>
...

fodg ファイルには複数ページのデータが含まれていますが、今回は 1ページ目だけを使い、2ページ目以降は無視します。

「ここに図形の記述が並ぶ」の部分を見てみましょう。

<draw:custom-shape draw:style-name="gr1" draw:text-style-name="P1" draw:layer="layout"
  svg:width="9.5cm" svg:height="3.8cm"
  svg:x="1.9cm" svg:y="2.9cm"
>
  <text:p text:style-name="P1">box1<text:line-break/>aa</text:p>
  <text:p text:style-name="P1"/>
  <text:p text:style-name="P1">bb</text:p>
  <draw:enhanced-geometry svg:viewBox="0 0 21600 21600"
    draw:type="rectangle"
    draw:enhanced-path="M 0 0 L 21600 0 21600 21600 0 21600 0 0 Z N"
  />
</draw:custom-shape>

<draw:custom-shape draw:style-name="gr2" draw:text-style-name="P1" draw:layer="layout"
  svg:width="2.5cm" svg:height="7.1cm"
  svg:x="13.2cm" svg:y="1.7cm"
>
  <text:p text:style-name="P1">box2</text:p>
  <draw:enhanced-geometry svg:viewBox="0 0 21600 21600"
    draw:type="rectangle"
    draw:enhanced-path="M 0 0 L 21600 0 21600 21600 0 21600 0 0 Z N"
  />
</draw:custom-shape>

draw:type="rectangle" の部分を見ることで矩形であることが判別でき、 svg:width, svg:height, svg:x, svg:y の部分から位置とサイズが抽出できそうですね。あとテキストも取れそうです。


Ruby と、標準ライブラリ REXML を使ってスクリプトを書きます *1

# coding: utf-8
require "rexml/document"

def xpath_match(el, xpath)
  return REXML::XPath.match(el, xpath)
end

def extract_pages(doc)
  return xpath_match(doc, "//draw:page")
end

def extract_rectangles(page)
  custom_shape_els = xpath_match(page, "draw:custom-shape")

  rect_els = custom_shape_els.select { |el|
    geo_el = xpath_match(el, "draw:enhanced-geometry")[0]
    geo_el["draw:type"] == "rectangle"
  }

  return rect_els
end

# 手抜き実装。改行が失われます。
def extract_text(el)
  texts = []
  el.each_element_with_text { |el|
    texts << el.texts.join(" ")
  }

  return texts.join(" ")
end

def print_rectangle(rect_el)
  print "x="       , rect_el["svg:x"]
  print ", y="     , rect_el["svg:y"]
  print ", width=" , rect_el["svg:width"]
  print ", height=", rect_el["svg:height"]
  print ", text="  , extract_text(rect_el)
  print "\n"
end

# --------------------------------

xml = File.read("sample_rectangle.fodg")
doc = REXML::Document.new(xml)

pages = extract_pages(doc)

rect_els = extract_rectangles(pages[0])

rect_els.each { |rect_el|
  print_rectangle(rect_el)
}

実行結果:

$ ruby extract_rectangles.rb 
x=1.9cm, y=2.9cm, width=9.5cm, height=3.8cm, text=box1 aa bb
x=13.2cm, y=1.7cm, width=2.5cm, height=7.1cm, text=box2

抽出できました! x, y はページ左端、上端の余白を含めた値になっているようです。

コネクタ

次の例としてコネクタです。

Draw でこんな図を描きます。

f:id:sonota88:20191201061646p:plain

ここからこういう情報が抜き出せればOK。

box1 => box3
box2 => box3
box3 => box4

XML を見るとこんな感じです。 コネクタが繋がっている場合は矩形要素に id が振られます。

<draw:custom-shape draw:style-name="gr1" draw:text-style-name="P1"
  xml:id="id2" draw:id="id2"
  draw:layer="layout" svg:width="2.6cm" svg:height="5.7cm" svg:x="9.9cm" svg:y="1.8cm"
>
  <text:p text:style-name="P1">box3</text:p>
  <draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="rectangle" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 0 21600 0 0 Z N"/>
</draw:custom-shape>

<draw:connector draw:style-name="gr2" draw:text-style-name="P2" draw:layer="layout" draw:type="curve" svg:x1="6.6cm" svg:y1="2.55cm" svg:x2="9.9cm" svg:y2="4.65cm"
  draw:start-shape="id1"
  draw:start-glue-point="1"
  draw:end-shape="id2"
  svg:d="M6600 2550c2475 0 825 2100 3300 2100" svg:viewBox="0 0 3301 2101"
>
  <text:p/>
</draw:connector>
...

やってみます。同様の記述が多くなるのでコードは gist に貼りました。

https://gist.github.com/sonota88/4a2221def064e675cabfce1a9266d48f#file-extract_connectors-rb

実行結果:

$ ruby extract_connectors.rb 
(id1) box1 => (id2) box3
(id3) box2 => (id2) box3
(id2) box3 => (id4) box4

いけますね。

応用編

コネクタを同じ箇所に複数つなげるとこのような見た目になります。

f:id:sonota88:20191201062408p:plain

これ、矢印が重なると分かりにくいんですよね。 この例でいえば、上から3番目のコネクタは両方向の矢印なのかな? とか、矢印が両方ともないコネクタもあるのかな? とか。

このように矢印がはっきり見えないと困るときや、 コネクタの接続箇所の位置を調整したいとき、 私はよくこういう描き方をします。

f:id:sonota88:20191201062442p:plain

ちなみに、まとめて選択すれば一緒に移動できます。

f:id:sonota88:20191201062750p:plain

この描き方を使って さっきのコネクタの図を描き直してみました。 今度はこの図から依存関係を抜き出してみましょう。

f:id:sonota88:20191111072708p:plain

こういうのが抜き出せればOK。上のコネクタの例と同じですね。

box1 => box3
box2 => box3
box3 => box4

この場合は単に抜き出すだけではなく、加工が必要です。

詳しくはコードを見ていただくとして、考え方としては

  • 矩形の重なりを判定して、どの矩形がどの矩形と繋がっているかを調べる
  • コネクタがテキストなし矩形に繋がっている場合は、 そこから辿ってテキストあり矩形を探す

みたいな感じですね。

https://gist.github.com/sonota88/4a2221def064e675cabfce1a9266d48f#file-extract_connectors_2-rb

$ ruby extract_connectors_2.rb 
(id1) box1 => () box3
(id3) box2 => () box3
() box3 => (id6) box4

いいですね。


もっとそれっぽい例で試してみましょう。 達人プログラマーピアソン・エデュケーション版 p156)に載っている、ピニャ・コラーダの作り方を記述したアクティビティ図(UML の一種)です。

f:id:sonota88:20191112070228p:plain

要素は増えてますが、さっきの例と同じルールで描いているので、 さっきのスクリプトで同じように抽出できるはず!

ここから抜き出した結果が下記です。

(id1) 2_ミックスを開ける => () join1
(id3) 1_ブレンダーを開ける => () join1
() join4 => (id6) 12_サーブする
(id3) 1_ブレンダーを開ける => (id7) 6_氷を2カップ入れる
(id8) 11_ピンクの傘を用意する => () join4
(id7) 6_氷を2カップ入れる => () join3
() join1 => (id12) 3_ミックスを入れる
(id12) 3_ミックスを入れる => () join3
(id14) 4_ラムを計る => () join2
(id16) 10_グラスの用意をする => () join4
(id18) 9_ブレンダーを開ける => () join4
(id20) 5_ラムを入れる => () join3
(id3) 1_ブレンダーを開ける => () join2
() join2 => (id20) 5_ラムを入れる
(id24) 8_かき混ぜる => (id18) 9_ブレンダーを開ける
(id25) 7_ブレンダーを閉める => (id24) 8_かき混ぜる
() join3 => (id25) 7_ブレンダーを閉める

アクティビティ図からタスクの依存関係を抜き出すツールができていました。 ちょろい!


というわけで、矩形とコネクタの情報を抜き出す例を紹介しました。 自分がよく使う図形と用途に合わせたやり方を把握しておくと 低コストで汎用エディタが用意できそうですね (これをもっと早く思いついていればなあ〜)。

今回は矩形とコネクタだけを扱いましたが、線や円など他の図形を使ったり、レイヤーやスタイルの情報も利用するとさまざまな活用ができそうです。

関連

せっかくのアドベントカレンダーですのでいくつか宣伝ぽく LibreOffice 関連記事へのリンクを貼ってみます。

(追記 2019-12-07)テキスト抽出の改良

図形内のテキストを文字列の配列として返すメソッドを書いてみました。 改行( text:line-break 要素)を LF に変換して 段落を一つの文字列にしています。

["box1\naa", "", "bb"] のような配列を返すので、全部繋げて一つの文字列にしたい場合は extract_paragraphs(el).join("\n") のように使えばよいかと。

def extract_paragraphs(el)
  para_els = xpath_match(el, "text:p")

  para_els.map { |para_el|
    para_el.children
      .map { |child_el|
        case child_el
        when REXML::Text
          child_el.value
        when REXML::Element
          if child_el.name == "line-break"
            "\n"
          else
            raise "unknown element"
          end
        else
          raise "unknown element"
        end
      }
      .join("")
  }
end

*1:Ruby に馴染みのない方のためにここだけ return を省略せずに書いています

JRubyでLibreOffice Calcのfodsファイルを読み書きするサンプル 2019

以前 JavaScript(Rhino/jrunscript)で書いたものを今さらながら Nashorn 向けに書きなおそうとして調べたところ、非推奨になっていました。

2018-06-07 JavaでJavaScriptを実行する「Nashorn」が非推奨に、ECMAScriptの速い進化に追いつけないと。代替案はGraalVM - Publickey

去年のニュースですね。全然気づいてませんでした。 GraalVM を使えとあり、それも面白そうではありますが時期尚早な感じもします。 ちょっと考えて JRuby で書き直してみることにしました。

sonota88/libreoffice-jruby-sample
https://github.com/sonota88/libreoffice-jruby-sample

Ubuntu で動かす前提のサンプルになっていて、 libreoffice-java-common をインストールしておく必要があります。ライブラリまわりについては一つ前の記事なども参考にしてください。 Windows などでもライブラリのパスの修正だけで動くんじゃないかと思います。

なんかデッドロックが発生してプログラムが終了しなかったのでサンプルスクリプトでは明示的に exit しています。 jstack を使ってデッドロックしているなーというとこまで調べたあたりで気力が尽きました。また気が向いたら調べるかも……。


今回はじめて JRuby を使ってみましたが、 zip をダウンロードして展開して bin/ にパスを通すだけで使えて、いいですね。分かりやすい。

JRuby から Java のライブラリなどを使う場合、下記は必読でした。まずこれを読みましょう。

CallingJavaFromJRuby · jruby/jruby Wiki


次のようにシートをダンプしてくれる dump.rb もおまけで追加しました。 値が入っている最大の行・列の取り方が分からなかったため、いったん100行・100列まで見るようにしました。 サンプルということで許してください……。

$ jruby dump.rb foo.fods {シート名}
["a1", "b1"]
["a2", "b2"]

参考: もっとお手軽な機械可読テキストテーブルフォーマット - memo88

JavaでLibreOffice Calcのfodsファイルを読み書きするサンプル 2019

5年前に JavaScript で書いたもの を大体そのまま Java に書き直しただけです。 例外のハンドリングは適当です。 今では推奨されない古い書き方が残ってたりするかもしれません。

sonota88/libreoffice-java-sample at 20191202
https://github.com/sonota88/libreoffice-java-sample/tree/20191202

処理の内容的には fods ファイルを開いてセルの内容の最低限の読み書きするというもの。


以下は今回調べたりしたことのメモです。 開発環境は Ubuntu 18.04。

jar の設定を pom.xml に書く

Java から LibreOfficeAPI を使う場合、 SDK をインストールして、それに付いてくる jar を使う、というのが普通のやり方だったと思います。たしか。

Eclipse の場合は

  • プロジェクトのプロパティ
  • Java Build Path>「Libraries」タブ>Add External JARs...

から追加します。 ここで jar を追加すると、プロジェクトの .classpath ファイルに

<classpathentry kind="lib" path="/usr/lib/libreoffice/program/classes/juh.jar"/>

このような設定が追加されます。

これだと Eclipse 用の設定になってしまうので、 pom.xml に書けないんだっけと思って調べたところ、 下記のように system スコープで dependency を書けばよいようでした。 (groupId, artifactId、バージョンは適当です)

<dependency>
  <groupId>juh-g</groupId>
  <artifactId>juh-a</artifactId>
  <version>0.0.1</version>
  <scope>system</scope>
  <systemPath>/usr/lib/libreoffice/program/classes/juh.jar</systemPath>
</dependency>

参考:

ただ、 jar はこれでいけるんですが、共有ライブラリ libjpipe.sopom.xml で設定できるか分からず、これだけ Eclipse 側で設定しました。 (Eclipse 上でユニットテストなどで実行するときに必要で、プログラム書いてコンパイルするだけなら不要っぽいです)

関連:
(solved) Exception in thread "main" java.lang.UnsatisfiedLinkError: no jpipe in java.library.path - memo88
https://memo88.hatenablog.com/entry/20140723/1406123992

Ubuntu 18.04 でのパッケージまわりのメモ

libreoffice-java-common ... unoil を含む
libreoffice-common ... ure に依存
ure ... juh, jurt, ridl, jpipe を含む

dpkg や apt-cache コマンドで調べられます:

パッケージに含まれるファイル一覧
dpkg -L {パッケージ名}

パッケージの依存関係
apt-cache depends {パッケージ名}
apt-cache rdepends {パッケージ名}

ところで libreoffice-dev というパッケージもありますがこれって何でしたっけ?

$ apt-cache depends libreoffice-dev           
libreoffice-dev
  Depends: libreoffice-core
  Depends: libreoffice-dev-common
  Depends: ucpp
  Depends: libc6
  Depends: libgcc1
  Depends: libstdc++6
  Depends: libx11-6
  Depends: uno-libs3
  Depends: ure
  Conflicts: libreoffice
  Conflicts: libreoffice-dev-doc
  Breaks: libreoffice-dev-common
  Recommends: g++
  Recommends: libreoffice-java-common
 |Recommends: default-jre
 |Recommends: <sun-java6-jre>
 |Recommends: <java6-runtime>
    default-jre
    openjdk-11-jre
    openjdk-8-jre
  Recommends: <jre>
  Suggests: libmythes-dev
  Suggests: libreoffice-dev-doc
  Suggests: libreofficekit-dev
  Replaces: libreoffice-dev-common

なるほど。パッケージの説明は

office productivity suite -- SDK -- architecture-dependent parts

となっています。

Mavenリポジトリにある jar を使う

共有ライブラリ libjpipe.so を除くと、他は jar を使っているだけといえばだけです(たぶん)。それなら、ひょっとして Mavenリポジトリから取ってきて普通の Maven プロジェクトっぽくできたりしないでしょうか?

探したら Maven のセントラルリポジトリにありました。

"org.libreoffice" の検索結果:
https://search.maven.org/search?q=org.libreoffice

これを使えば、 libreoffice-java-common をインストールしなくても必要な jar を Maven の流儀に従って使えばよく、より普通の Maven プロジェクトっぽく扱えて嬉しいような。

pom.xml に普通にこんな感じで書けばよいと。普通ですね。いいですね。

    <dependency>
      <groupId>org.libreoffice</groupId>
      <artifactId>ridl</artifactId>
      <version>6.3.2</version>
    </dependency>

ふむふむ、いいじゃない、となったのですが、 この方法だと 5年前のこれと同じところで引っかかるのです……。

(solved) com.sun.star.comp.helper.BootstrapException: no office executable found! - memo88
https://memo88.hatenablog.com/entry/20140721/1405966864

上記の記事から5年経ちましたが、 Bootstrap クラスが含まれている juh.jar の位置を起点にして実行ファイル soffice を探す部分は変わっていないようで、 今回のサンプルでは Mavenリポジトリを利用する方向は見送りました。

Maven でライブラリ取ってくると ~/.m2/ 以下に jar が入ったり、 fat jar 作ったらその中に入ったりするので、そこから相対パスで探しても soffice が見つけられないんですよね……。

本体のコード(Bootstrap.java) を借用&修正して使っても動きましたが、その場合は公開の際に本体のライセンスに従う必要があるでしょう。

Docker コンテナで実行する

先日 LibreOffice 本体だけ Docker で動かすメモ を書きましたが、 ついでに SDK もイメージに入れておけば便利かも? と思いついて、これも試してみました。

以下の3つのパッケージを入れておけば今回のサンプルは動きました。

  • libreoffice-calc
  • libreoffice-java-common
  • openjdk-8-jre

あとはコンテナ内で

java -cp "{ライブラリのパス}:{ビルドしたjarのパス}" \
  sample.Main {残りの引数}

で実行できます。 詳しくはリポジトリに入っているスクリプト run.shDockerfile を見てください。

OCaml ちょっとやってみるための準備(Docker + Ubuntu 18.04)

  • Docker + Ubuntu 18.04 での環境の用意
  • とりあえず cat コマンドを書いてみた

たぶん3日坊主になると思うので、 ホスト側の環境を汚さないように Docker で環境を用意する。 ホストも Ubuntu です。

この記事を参考に。何も分からないので助かります。
WSL の Ubuntu 18.04 で OCaml 開発環境を作る - Qiita

FROM ubuntu:18.04

RUN apt update \
  && apt install -y --no-install-recommends \
    ca-certificates \
    curl \
    m4 \
    nano \
    ocaml \
    opam \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

ARG USER
ARG GROUP

RUN groupadd ${USER} \
  && useradd ${USER} -g ${GROUP} -m

USER ${USER}

RUN mkdir /home/${USER}/work

WORKDIR /home/${USER}/work

コーディングはホスト側で行うので Emacs は省く。

イメージをビルド

docker build \
  --build-arg USER=$USER \
  --build-arg GROUP=$(id -gn) \
  -t ocaml-env:test .

コンテナを起動

docker run -it \
  -v ${PWD}:/home/user/work \
  --name ocaml-env-container \
  ocaml-env:test

コンテナ内で残りの作業。 コーディングはホスト側で行うので tuareg などは省いて utop だけインストール。

opam init -y

eval `opam config env`

echo "" >> ~/.bashrc
echo 'eval `opam config env`' >> ~/.bashrc

opam install -y utop

yes | opam install ... だとうまくいかなくて仕方なく対話的にやっていたけど、 -y オプションを見つけて解決した。 ので、これらの opam まわりの作業も Dockerfile に入れてしまえるはず。)

exit で抜けたりした後またコンテナ内に入りたいときは

# コンテナが止まっていたら start で再開して
# docker start ocaml-env-container

docker exec -it ocaml-env-container bash

ホスト側でコードを書く。

https://github.com/ocaml/tuareg

から tuareg.el だけ取ってきてカレントディレクトリに置いて、 Emacs

(progn
  (load (concat (file-name-as-directory default-directory)
                "tuareg.el"))
  (tuareg-mode))

を評価すると、構文ハイライトされて M-;コメントアウト・コメントインできるようになる。 賢い補完とかはできないけど、そこらへんは3日坊主にならなかったら整えるということで。


cat コマンド書く。

参考: お気楽 OCaml プログラミング入門 (8) ファイル入出力 | M.Hiroi's Home Page

参考にして書いてたつもりが、いじってたら結局 M.Hiroi さんのコード例のまんまになってしまいましたね。

(* cat.ml *)

let () =
  let rec cat_line () =
    print_string (read_line ());
    print_newline ();
    cat_line () in
  try
    cat_line ()
  with
    End_of_file -> ();;

コンテナ内で実行。

ls | ocaml cat.ml

LibreOffice Calcの入ったDockerイメージを作ってヘッドレスで動かす

とりあえず自分が使いやすいようにポータブルにしておくとよいかも? くらいの思いつきでやってみましたが、たとえば開発チームのメンバーにちょっとしたツールを渡したいとき(そのためだけに LibreOffice をインストールしてもらわなくて済む)とか、サーバの環境を汚さずに使いたいときに都合が良いかもしれませんね。


下記では例として Calc が入ったイメージを作って fods ファイルを ods ファイルに変換します。 Writer とかでも同じようにできるのではないでしょうか。


Dockerfile 書く。 関連しそうなパッケージはいくつかあるのですが、とりあえず libreoffice-calc をインストールすれば変換処理は動きました。

FROM ubuntu:18.04

RUN apt-get update \
  && apt-get -y install --no-install-recommends \
    libreoffice-calc \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

イメージをビルド。

docker build -t libo_calc:test .

イメージのサイズは 426MB になりました。


コンテナ内で実行するシェルスクリプト container_fods2ods.sh を用意。 汎用化は置いておいてひとまずベタ書きです。

あと、動作する最低限のサンプルということで --headless だけ付けていますが、 他にも --nologo などのオプションがあります。詳しくは LibreOfficeでドキュメントコンバータを作ろう - Qiita を参照してください。

temp_fods=/tmp/temp.fods
temp_ods=/tmp/temp.ods

# 標準入力から受け取る
cat > $temp_fods

cd /tmp

soffice \
  --headless \
  --convert-to ods \
  $temp_fods \
  >&2
# => /tmp/temp.ods に出力される

cat $temp_ods

下記のようなメッセージが標準出力に出て都合が悪いので >&2標準エラー出力にリダイレクトしています。 Java の実行環境がないよと言われてますが、今やろうとしている fods → ods の変換に関しては問題ないようなのでいったん無視。

javaldx: Could not find a Java Runtime Environment!
Please ensure that a JVM and the package libreoffice-java-common
is installed.
If it is already installed then try removing ~/.libreoffice/3/user/config/javasettings_Linux_*.xml
Warning: failed to read path from javaldx
convert /tmp/temp.fods -> /tmp/temp.ods using filter : calc8

ホスト側でコマンドとして使うシェルスクリプト fods2ods.sh を用意。

#!/bin/bash

file_in="$1"; shift
file_out="$1"; shift

cat $file_in \
  | docker run --rm -i \
      -v "$(pwd):/root/work/" \
      libo_calc:test \
      bash /root/work/container_fods2ods.sh \
  > $file_out

実行。

chmod u+x fods2ods.sh
./fods2ods.sh sample.fods sample_output.ods

参考

Open JTalkのDockerイメージを作って手軽に喋らせる

Docker イメージとラッパースクリプトを作っておいて echo "ほげ" | ./talk.sh みたいな感じで手軽に喋らせたい。

改善の余地はありますがとりあえず動いた段階での最低限なメモです。


Dockerfile 書く:

FROM ubuntu:18.04

RUN apt-get update \
  && apt-get -y install --no-install-recommends \
    hts-voice-nitech-jp-atr503-m001 \
    open-jtalk \
    open-jtalk-mecab-naist-jdic \
    sox \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

WORKDIR /root/work

sox は必須ではないが音量調節のためにどうせ使うので一緒に入れておいた。


イメージをビルド:

docker build -t my-open-jtalk:0.0.1 .

コンテナ内で実行させるスクリプト container_open_jtalk.sh を用意:

open_jtalk \
  -x /var/lib/mecab/dic/open-jtalk/naist-jdic \
  -m /usr/share/hts-voice/nitech-jp-atr503-m001/nitech_jp_atr503_m001.htsvoice \
  -ow /tmp/output.wav

cat /tmp/output.wav

同じくコンテナ内で実行させるスクリプト container_sox.sh を用意:

cat > /tmp/input.wav

# 音量調節
sox -v 0.5 /tmp/input.wav /tmp/output.wav

cat /tmp/output.wav

sox の入出力をパイプにつなげる方法を調べかけたけど、 めんどくさくなったのでひとまず cat と一時ファイルで wrap。。。


コマンドとして使う talk.sh を用意:

#!/bin/bash

gen_wav(){
  docker run --rm -i --name my-open-jtalk \
      -v $(pwd):/root/work \
      my-open-jtalk:0.0.1 \
      /bin/bash /root/work/container_open_jtalk.sh
}

adjust_vol(){
  docker run --rm -i --name my-open-jtalk-sox \
      -v $(pwd):/root/work \
      my-open-jtalk:0.0.1 \
      /bin/bash /root/work/container_sox.sh
}

# 入力は標準入力から受け取る
gen_wav \
  | adjust_vol \
  | aplay --quiet

aplay はホスト側のもの(※ ちなみにホストも Ubuntu)。 別のディレクトリから実行すると pwd がずれて動かないので必要なら適宜なんとかする。


実行:

chmod u+x talk.sh
echo "ほげ" | ./talk.sh
date "+現在の時刻は %-H時 %-M分 です" | ./talk.sh

mei の音声データを使う場合

Dockerfile をこうして

FROM ubuntu:18.04

RUN apt-get ...(略。上記のに加えて wget, unzip もインストールする)

WORKDIR /tmp

RUN wget --no-check-certificate \
      https://sourceforge.net/projects/mmdagent/files/MMDAgent_Example/MMDAgent_Example-1.7/MMDAgent_Example-1.7.zip \
  && unzip MMDAgent_Example-1.7.zip \
  && cp -r MMDAgent_Example-1.7/Voice/mei/ /usr/share/hts-voice/

WORKDIR /root/work

open_jtalk の -m オプションで /usr/share/hts-voice/mei/mei_normal.htsvoice を指定するのじゃ。

参考

vm2gol v2 製作メモ(38) おまけの修正


ゴール設定が「ライフゲームが動けばOK」 だったので、前回で終わり、としてもよかったんですが、 せっかくなのでもうちょっとだけいじります。 すでにライフゲームが動いており目的は達成されているので今回のはおまけです。

第24回 で触れた入れ子の式の問題については別記事で書く予定

ダンプ表示の改良(コメントに色を付ける)

これ v1 のときはやってたんですが、 v2 では単に忘れてた……だった気がします。 忘れていたことを忘れていた? 時間が経つといろんなことを忘れますね。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -7,6 +7,7 @@ require './common'
 module TermColor
   RESET  = "\e[m"
   RED    = "\e[0;31m"
+  BLUE   = "\e[0;34m"
 end
 
 class Memory
@@ -55,6 +56,8 @@ class Memory
         case operator
         when "exit", "call", "ret", "jump", "jump_eq"
           TermColor::RED
+        when "_cmt"
+          TermColor::BLUE
         else
           ""
         end

こんな見た目になります。

f:id:sonota88:20191005110106p:plain

3行だけの修正で見やすくなるので (31) 生存カウント (2) / _cmt の時に入れておくべきでしたね……。

ダンプ表示を間引きして高速化

今の状態だと、グライダーが1周する (右下に移動して左上にワープして、元の位置に戻る) のに4分くらいかかります。

こういう、ターミナルにドバドバ出力するプログラムは ターミナルでの画面表示がボトルネックになる場合が多いので、 単純にダンプ表示を間引きしてやれば速くなる気がします。

動作確認やデバッグのときは1ステップずつ進める必要がありますが、 ライフゲームが動いているのを眺めて愛でる段階では 律儀に毎ステップ表示せずに適当にサボらせればよいでしょう。 とりあえず 「10ステップごとに1回だけダンプ表示を行う」 ようにしてみます。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -318,7 +318,7 @@ class Vm
         raise "Unknown operator (#{op})"
       end
 
-      dump_v2()
+      dump_v2() if @step % 10 == 0
       # $stdin.gets if @step >= 600
       # sleep 0.01
     end

やはりこれだけでかなり速くなりますね。 20秒くらいで元の位置に戻るようになりました。

適当に10ステップ毎としましたが、 1周するのに 40万ステップ弱(!)かかるようになっているので、 表示を眺める分にはもっと間引いても問題ありません。

VM: インラインで書いていた命令ごとの処理をメソッドに抽出

Vm#start() が長い。 行数を数えてみると 175行ありました。

case式の中に直接書いている処理をメソッドに抽出しましょう。例として push

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -218,29 +218,7 @@ class Vm
         @pc = ret_addr # 戻る
         set_sp(@sp + 1) # スタックポインタを戻す
       when "push"
-        arg = @mem.main[@pc + 1]
-        val_to_push =
-          case arg
-          when Integer
-            arg
-          when String
-            case arg
-            when "bp"
-              @bp
-            when /^\[bp\-(\d+)\]$/
-              stack_addr = @bp - $1.to_i
-              @mem.stack[stack_addr]
-            when /^\[bp\+(\d+)\]$/
-              stack_addr = @bp + $1.to_i
-              @mem.stack[stack_addr]
-            else
-              raise not_yet_impl("push", arg)
-            end
-          else
-            raise not_yet_impl("push", arg)
-          end
-        set_sp(@sp - 1)
-        @mem.stack[@sp] = val_to_push
+        push()
         @pc += pc_delta
       when "pop"
         arg = @mem.main[@pc + 1]
@@ -464,6 +442,34 @@ class Vm
       @pc += 2
     end
   end
+
+  def push
+    arg = @mem.main[@pc + 1]
+
+    val_to_push =
+      case arg
+      when Integer
+        arg
+      when String
+        case arg
+        when "bp"
+          @bp
+        when /^\[bp\-(\d+)\]$/
+          stack_addr = @bp - $1.to_i
+          @mem.stack[stack_addr]
+        when /^\[bp\+(\d+)\]$/
+          stack_addr = @bp + $1.to_i
+          @mem.stack[stack_addr]
+        else
+          raise not_yet_impl("push", arg)
+        end
+      else
+        raise not_yet_impl("push", arg)
+      end
+
+    set_sp(@sp - 1)
+    @mem.stack[@sp] = val_to_push
+  end
 end
 
 exe_file = ARGV[0]

同様に、 pop, set_vram, get_vram もメソッドに抽出しました(diff は省略)。

ライフゲームが動くようになっていますので、 「ライフゲームの動きが(目で見て)おかしくなっていないこと」 をテスト代わりにして、壊れていないことを確認します。 適当だなー。

適当ですが、テストがまだない状態で無理してリファクタリングするよりは、 ちょっと我慢してリファクタリングを後回しにして、 雑でもテストで保護された状態になってから修正する方が安心感がありますね。

codegen_stmts() と codegen_func_def() の共通化

微妙に気にはなっていたところですが。

うーん、これはちょっと考えたんですが、 無理して共通化しなくてもいいかなと。やってもいいけど。 v1 のときはやってたんですけどね。

気が向いたらやるかも。

使わなくなったメソッドの削除

そういえばそんなのあったな的な。 一番最初に使ったやつみたいですね(忘れてる)。 消しても大丈夫。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -310,22 +310,6 @@ class Vm
     EOB
   end
 
-  def set_mem(addr, n)
-    @mem.main[addr] = n
-  end
-
-  def copy_mem_to_reg_a(addr)
-    @reg_a = @mem.main[addr]
-  end
-
-  def copy_mem_to_reg_b(addr)
-    @reg_b = @mem.main[addr]
-  end
-
-  def copy_reg_c_to_mem(addr)
-    @mem.main[addr] = @reg_c
-  end
-
   def add_ab
     @reg_a = @reg_a + @reg_b
   end

その他メモ

  • なんとなく残したままにしてますが、reg_c は 第5〜7回で使ったあと使わなくなってしまったので、関連箇所を削除してもライフゲームは動きます。

他にもリファクタリングしたり改良したいところはいろいろあるのですが、 だらだらやっているとキリがないのでいったんここでやめておきます。 (気になるところが出てきたらまた追記するかもしれません)

2019-12-14 追記

  • gol.vgt.json: テスト用のコードとコメントアウトしていた部分を一部削除しました