vm2gol v2 製作メモ(40) 落ち穂拾い: ローカル変数宣言時に初期値をセット



これは簡単なのでサラッとやります。

(18) ローカル変数の宣言と代入 / var, set文 のときにちょっと触れていた件です。 だいぶ枝葉な話ですね。


  ["var", "x"]
, ["set", "x", 12]

このように2つの文に分けて書いていたのを、次のように 1つの文で書けるようにしたい。

  ["var", "x", 12]

アセンブリコードのレイヤーではこうですね。 var文と set文でそれぞれを出力していたのを、 var文だけで両方出力されるようにしたい。

sub_sp 1       # ローカル変数の宣言
cp 12 [bp-{N}] # ローカル変数に値をセット

動作確認用のコードを用意。

// 40_var_init.vgt.json 

["stmts"
, ["func", "main", []
  , [
      ["var", "a"]
    , ["var", "b", 12]
    ]
  ]
]

修正する箇所は codegen_func_def() の var文の箇所。

def codegen_func_def(rest)
  # ...
  body = rest[2]
  # ...
  lvar_names = []

  body.each {|stmt|
    stmt_head, *stmt_rest = stmt
    case stmt_head
    # ...
    when "var"
      lvar_names << stmt_rest[0]
      alines << "  sub_sp 1"
    when "set"
    # ...

var文の引数(stmt_rest)の数を見て、初期値が指定されていたら cp {初期値} [bp-{N}] を出力する、という修正で良さそうです。

それを愚直に書いてもいいのですが、 ここは横着して codegen_set() がそのまま使えそうです。 横着というか意味的にもそうするのが適切なように思えます。 そうしましょう。

--- a/vgcg.rb
+++ b/vgcg.rb
@@ -390,6 +390,9 @@ def codegen_func_def(rest)
     when "var"
       lvar_names << stmt_rest[0]
       alines << "  sub_sp 1"
+      if stmt_rest.size == 2
+        alines += codegen_set(fn_arg_names, lvar_names, stmt_rest)
+      end
     when "set"
       alines += codegen_set(fn_arg_names, lvar_names, stmt_rest)
     when "eq"

できました。簡単〜。

動かして確認。

$ STEP= ./run.sh 40_var_init.vgt.json
...
================================
6: reg_a(0) reg_b(0) reg_c(0) zf(0)
---- memory (main) ----
      00   ["call", 5]
      02   ["exit"]
      03 ["label", "main"]
      05   ["push", "bp"]
      07   ["cp", "sp", "bp"]
      10   ["sub_sp", 1]
      12   ["sub_sp", 1]        ... ローカル変数 b の宣言
      14   ["cp", 12, "[bp-2]"] ... ローカル変数 b に初期値をセット
pc => 17   ["cp", "bp", "sp"]
      20   ["pop", "bp"]
      22   ["ret"]
---- memory (stack) ----
         37 0
         38 0
         39 0
         40 0
         41 0
         42 0
         43 0
         44 0
sp    => 45 12 ... ローカル変数 b
         46 0
   bp => 47 49
         48 2
         49 0

大丈夫そうですね。


ではライフゲームの方も書き換えます。

--- a/gol.vgt.json
+++ b/gol.vgt.json
@@ -63,8 +63,7 @@
 , ["func", "calc_next_gen", ["current_val", "count"]
   , [
       // 注目しているセルの次世代の生死
-      ["var", "next_val"]
-    , ["set", "next_val", 0]
+      ["var", "next_val", 0]
 
     , ["case"
       , [["eq", "current_val", 0]
@@ -88,8 +87,7 @@
 
 , ["func", "count_alive", ["w", "h", "x", "y"]
   , [
-      ["var", "count"]
-    , ["set", "count", 0]
+      ["var", "count", 0]
 
     , ["var", "xl"]
     , ["var", "xr"]
@@ -161,11 +159,8 @@
 
 , ["func", "make_next_gen", ["w", "h"]
   , [
-      ["var", "x"]
-    , ["set", "x", 0]
-
-    , ["var", "y"]
-    , ["set", "y", 0]
+      ["var", "x", 0]
+    , ["var", "y", 0]
 
     , ["var", "count"]
       // 注目しているセルの現世代の生死
@@ -203,8 +198,7 @@
 
 , ["func", "replace_with_buf", []
   , [
-      ["var", "vi"]
-    , ["set", "vi", 0]
+      ["var", "vi", 0]
 
     , ["var", "vi_buf"]
     , ["var", "temp"]
@@ -222,10 +216,8 @@
 
 , ["func", "main", []
   , [
-      ["var", "w"] // 盤面の幅
-    , ["set", "w", 5]
-    , ["var", "h"] // 盤面の高さ
-    , ["set", "h", 5]
+      ["var", "w", 5] // 盤面の幅
+    , ["var", "h", 5] // 盤面の高さ
 
       // 初期状態の設定
     , ["call", "vram_set", "w", 1, 0, 1]

codegen_set() を流用したことにより、 ["+", 1, 2] のような二項の式も初期値として指定できるようになっています。

なので、次の箇所も書き換えられます。

--- a/gol.vgt.json
+++ b/gol.vgt.json
@@ -89,15 +89,10 @@
   , [
       ["var", "count", 0]
 
-    , ["var", "xl"]
-    , ["var", "xr"]
-    , ["var", "yt"]
-    , ["var", "yb"]
-
-    , ["set", "xl", ["+", "x", -1]]
-    , ["set", "xr", ["+", "x",  1]]
-    , ["set", "yt", ["+", "y", -1]]
-    , ["set", "yb", ["+", "y",  1]]
+    , ["var", "xl", ["+", "x", -1]]
+    , ["var", "xr", ["+", "x",  1]]
+    , ["var", "yt", ["+", "y", -1]]
+    , ["var", "yb", ["+", "y",  1]]
 
     , ["_cmt", "★ xl の補正の直前"]
     , ["call_set", "xl", ["adjust_index", "w", "xl"]]

ライフゲームを実行すると……壊れていないようですね。OKです!

gol.vgt.json は前回と比べて 13行減り、見た目がちょっとすっきりしました。 枝葉だけど微妙に気にはなっていたので、気分的にもまたちょっとすっきりです。

vm2gol v2 製作メモ(39) 落ち穂拾い: 入れ子の式を書けるようにする



第24回 入れ子の式 で泣く泣く見送ったアレをやっつけます!

枝葉の修正(1) ステップ実行の切り替え

先に枝葉っぽい部分を片付けます。

前回 ダンプ表示を間引きしてライフゲームの実行を高速化しましたが、 今回また、

  • 確認用の小さなプログラムをステップ実行して命令レベルでの動作を確認
  • 問題なければライフゲームを実行して壊れていないことを確認

という作業を繰り返すことになります。 そのたびに vgvm.rb を書き直すのはさすがに面倒だったため、 環境変数で切り替えられるようにしました。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -235,8 +235,14 @@ class Vm
         raise "Unknown operator (#{op})"
       end
 
-      dump_v2() if @step % 10 == 0
-      # $stdin.gets if @step >= 600
+      if ENV.key?("STEP")
+        dump_v2()
+        $stdin.gets
+        # $stdin.gets if @step >= 600
+      else
+        dump_v2() if @step % 10 == 0
+      end
+
       # sleep 0.01
     end
   end
  • ダンプ表示の間引きのステップ数
  • ステップ実行する場合にスキップするステップ数

あたりもついでに指定できるようにしたくなりますが、 とりあえず今必要なものだけということで 環境変数 STEP の有無によって切り替えるだけにしました。

ライフゲームを実行する場合は今までどおり

./run.sh gol.vgt.json

で、ステップ実行したい場合は

STEP= ./run.sh 39_nested_exp.vgt.json

のように頭に STEP= を付けて実行します。

枝葉の修正(2) push, pop の修正

今回、次の3つの操作が必要になるので先に片付けておきます。

push reg_a ... reg_a の値をスタックに push
pop reg_a ... pop した値を reg_a にセット
pop reg_b ... pop した値を reg_b にセット
--- a/vgvm.rb
+++ b/vgvm.rb
@@ -381,6 +381,8 @@ class Vm
         arg
       when String
         case arg
+        when "reg_a"
+          @reg_a
         when "bp"
           @bp
         when /^\[bp\-(\d+)\]$/
@@ -404,6 +406,10 @@ class Vm
     arg = @mem.main[@pc + 1]
 
     case arg
+    when "reg_a"
+      @reg_a = @mem.stack[@sp]
+    when "reg_b"
+      @reg_b = @mem.stack[@sp]
     when "bp"
       @bp = @mem.stack[@sp]
     else

本題

さて、枝葉が片付いたところでここからが本題です。

第24回

低レイヤを知りたい人のためのCコンパイラ作成入門>スタックマシン
https://www.sigbus.info/compilerbook#%E3%82%B9%E3%82%BF%E3%83%83%E3%82%AF%E3%83%9E%E3%82%B7%E3%83%B3

v1 を作った後で読みました。 「スタックマシン」の節に答えが載ってますね。

と書いた通り、必要なことは 「低レイヤを知りたい人のためのCコンパイラ作成入門」 で解説されてますので、 考え方についてはそちらを参照してください。 自分が改めて付け加えることはありませんね。ありがたや……。


まず、入れ子になっていない式の処理が壊れないことを確認しながら、 リファクタリングします。

確認用のコードを用意。

// 39_nested_exp.vgt.json

["stmts"

, ["func", "main", []
  , [
      ["var", "a"]

    , ["set", "a", ["+", 1, 2]]
    ]
  ]

]

左項、右項をそれぞれ reg_areg_b に直接セットしていた箇所を、 一度スタックを経由するように修正します。

まず他に影響を与えないように + だけ。

--- a/vgcg.rb
+++ b/vgcg.rb
@@ -141,8 +141,11 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
 
   case operator
   when "+"
-    alines << "  set_reg_a #{left}"
-    alines << "  set_reg_b #{right}"
+    alines << "  push #{left}"
+    alines << "  push #{right}"
+    alines << "  pop reg_b"
+    alines << "  pop reg_a"
+
     alines << "  add_ab"
   when "*"
     alines << "  set_reg_a #{left}"

関数呼び出しのときと同じように、 push と pop を同じ回数行っているのがポイントです。 ここに気をつけておけば、 スタックポインタがおかしくなることはないでしょう。 この後の工程でも この収支が狂わないように気をつけてやっていきます。

結果の渡し方については、 これまで 「何かしら処理したら結果を reg_a にセットする」 というルールで作ってきましたので、 そこから外れないようにします。 スタックマシンのエミュレーションとしては 結果がスタックトップに置かれてほしいところですが、 ひとまず「入れ子の式が書けるようにする」をゴールとして 先に進みます。

(あれこれやっていると記事が書き終わらないのです……あと前回からの差分も見にくくなるし……と言い訳しつつ)


10: reg_a(3) reg_b(2) reg_c(0) zf(0)
---- memory (main) ----
      00   ["call", 5]
      02   ["exit"]
      03 ["label", "main"]
      05   ["push", "bp"]
      07   ["cp", "sp", "bp"]
      10   ["sub_sp", 1]
      12   ["push", 1]
      14   ["push", 2]
      16   ["pop", "reg_b"]
      18   ["pop", "reg_a"]
      20   ["add_ab"]
      21   ["cp", "reg_a", "[bp-1]"]
pc => 24   ["cp", "bp", "sp"]
      27   ["pop", "bp"]
      29   ["ret"]
---- memory (stack) ----
         38 0
         39 0
         40 0
         41 0
         42 0
         43 0
         44 2
         45 1
sp    => 46 3
   bp => 47 49
         48 2
         49 0

1 + 2 の結果がローカル変数 a にセットされました。 よしよし、足し算は壊れていないですね。

特に不安はありませんが、 手間はかからないですし 念のためライフゲームも動かして壊れていないことを確認します。


codegen_exp() には他に 掛け算 *eqneq があります。 これらも足し算と同じように、動作を確認しながら push + pop に書き換えていきます。

--- a/39_nested_exp.vgt.json
+++ b/39_nested_exp.vgt.json
@@ -4,7 +4,22 @@
   , [
       ["var", "a"]
 
-    , ["set", "a", ["+", 1, 2]]
+    // , ["set", "a", ["+", 1, 2]]
+
+    // , ["set", "a", ["*", 2, 3]]
+
+    // , ["set", "a", ["eq", 1, 1]]
+    // , ["_cmt", "等しい場合: 1 になること"]
+
+    // , ["set", "a", ["eq", 1, 2]]
+    // , ["_cmt", "等しくない場合: 0 になること"]
+
+    , ["set", "a", ["neq", 1, 1]]
+    , ["_cmt", "等しい場合: 0 になること"]
+
+    , ["set", "a", ["neq", 1, 2]]
+    , ["_cmt", "等しくない場合: 1 になること"]
+
     ]
   ]
--- a/vgcg.rb
+++ b/vgcg.rb
@@ -148,15 +148,21 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
 
     alines << "  add_ab"
   when "*"
-    alines << "  set_reg_a #{left}"
-    alines << "  set_reg_b #{right}"
+    alines << "  push #{left}"
+    alines << "  push #{right}"
+    alines << "  pop reg_b"
+    alines << "  pop reg_a"
+
     alines << "  mult_ab"
   when "eq"
     $label_id += 1
     label_id = $label_id
 
-    alines << "  set_reg_a #{left}"
-    alines << "  set_reg_b #{right}"
+    alines << "  push #{left}"
+    alines << "  push #{right}"
+    alines << "  pop reg_b"
+    alines << "  pop reg_a"
+
     alines << "  compare"
     alines << "  jump_eq then_#{label_id}"
 
@@ -173,8 +179,11 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
     $label_id += 1
     label_id = $label_id
 
-    alines << "  set_reg_a #{left}"
-    alines << "  set_reg_b #{right}"
+    alines << "  push #{left}"
+    alines << "  push #{right}"
+    alines << "  pop reg_b"
+    alines << "  pop reg_a"
+
     alines << "  compare"
     alines << "  jump_eq then_#{label_id}"

問題なさそうなので、 push left 、 push right を case の外に出して、 それぞれ 「左項の評価(のコード生成)が終わった直後」 「右項の評価(のコード生成)が終わった直後」 の位置に移動させます。

--- a/vgcg.rb
+++ b/vgcg.rb
@@ -122,6 +122,8 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
       raise not_yet_impl("left", args[0])
     end
 
+  alines << "  push #{left}"
+
   right =
     case args[1]
     when Integer
@@ -139,17 +141,15 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
       raise not_yet_impl("right", args[1])
     end
 
+  alines << "  push #{right}"
+
   case operator
   when "+"
-    alines << "  push #{left}"
-    alines << "  push #{right}"
     alines << "  pop reg_b"
     alines << "  pop reg_a"
 
     alines << "  add_ab"
   when "*"
-    alines << "  push #{left}"
-    alines << "  push #{right}"
     alines << "  pop reg_b"
     alines << "  pop reg_a"
 
@@ -158,8 +158,6 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
     $label_id += 1
     label_id = $label_id
 
-    alines << "  push #{left}"
-    alines << "  push #{right}"
     alines << "  pop reg_b"
     alines << "  pop reg_a"
 
@@ -179,8 +177,6 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
     $label_id += 1
     label_id = $label_id
 
-    alines << "  push #{left}"
-    alines << "  push #{right}"
     alines << "  pop reg_b"
     alines << "  pop reg_a"

というわけで、 「入れ子の式に対応させる修正のための準備」 が済みました。 ここからがほんとの本題。


まずは左項が入れ子の場合をやってみます。

--- a/39_nested_exp.vgt.json
+++ b/39_nested_exp.vgt.json
@@ -14,11 +14,19 @@
     // , ["set", "a", ["eq", 1, 2]]
     // , ["_cmt", "等しくない場合: 0 になること"]
 
-    , ["set", "a", ["neq", 1, 1]]
-    , ["_cmt", "等しい場合: 0 になること"]
-
-    , ["set", "a", ["neq", 1, 2]]
-    , ["_cmt", "等しくない場合: 1 になること"]
+    // , ["set", "a", ["neq", 1, 1]]
+    // , ["_cmt", "等しい場合: 0 になること"]
+
+    // , ["set", "a", ["neq", 1, 2]]
+    // , ["_cmt", "等しくない場合: 1 になること"]
+
+    // 左項が入れ子になっているパターン
+    , ["set", "a"
+      , ["*"
+        , ["+", 2, 3]
+        , 4
+        ]
+      ]
 
     ]
   ]

(2 + 3) * 4 ですね。結果が 20 になれば OK。

左項の評価部分は今はこうなってますので、

  left =
    case args[0]
    when Integer
      args[0]
    when String
      case
      when lvar_names.include?(args[0])
        to_lvar_addr(lvar_names, args[0])
      when fn_arg_names.include?(args[0])
        to_fn_arg_addr(fn_arg_names, args[0])
      else
        raise not_yet_impl("left", args[0])
      end
    else
      raise not_yet_impl("left", args[0])
    end

ここに入れ子の子として式が来た場合の分岐を追加してやればよいでしょう。

--- a/vgcg.rb
+++ b/vgcg.rb
@@ -118,6 +118,9 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
       else
         raise not_yet_impl("left", args[0])
       end
+    when Array
+      alines += codegen_exp(fn_arg_names, lvar_names, args[0])
+      "reg_a"
     else
       raise not_yet_impl("left", args[0])
     end

はい、これだけ。 codegen_exp()再帰的に呼び出されるようになりました。

15: reg_a(20) reg_b(4) reg_c(0) zf(0)
---- memory (main) ----
      03 ["label", "main"]
      05   ["push", "bp"]
      07   ["cp", "sp", "bp"]
      10   ["sub_sp", 1]
      12   ["push", 2]
      14   ["push", 3]
      16   ["pop", "reg_b"]
      18   ["pop", "reg_a"]
      20   ["add_ab"]
      21   ["push", "reg_a"]
      23   ["push", 4]
      25   ["pop", "reg_b"]
      27   ["pop", "reg_a"]
      29   ["mult_ab"]
      30   ["cp", "reg_a", "[bp-1]"]
pc => 33   ["cp", "bp", "sp"]
      36   ["pop", "bp"]
      38   ["ret"]
---- memory (stack) ----
         38 0
         39 0
         40 0
         41 0
         42 0
         43 0
         44 4
         45 5
sp    => 46 20
   bp => 47 49
         48 2
         49 0

正しい結果になっています! おー!


右項も同様に。

--- a/39_nested_exp.vgt.json
+++ b/39_nested_exp.vgt.json
@@ -20,11 +20,20 @@
     // , ["set", "a", ["neq", 1, 2]]
     // , ["_cmt", "等しくない場合: 1 になること"]
 
-    // 左項が入れ子になっているパターン
+    // // 左項が入れ子になっているパターン
+    // , ["set", "a"
+    //   , ["*"
+    //     , ["+", 2, 3]
+    //     , 4
+    //     ]
+    //   ]
+    // ]
+
+    // 右項が入れ子になっているパターン
     , ["set", "a"
       , ["*"
-        , ["+", 2, 3]
         , 4
+        , ["+", 2, 3]
         ]
       ]
--- a/vgcg.rb
+++ b/vgcg.rb
@@ -140,6 +140,9 @@ def codegen_exp(fn_arg_names, lvar_names, exp)
       else
         raise not_yet_impl("right", args[1])
       end
+    when Array
+      alines += codegen_exp(fn_arg_names, lvar_names, args[1])
+      "reg_a"
     else
       raise not_yet_impl("right", args[1])
     end

これもヨシ!

さらに、左右の項が両方とも入れ子になっているパターン、 2段以上の入れ子になっているパターンなどを確認しました。 問題なさそうです。


問題なさそうなので、gol.vgt.json入れ子の式に書き直しましょう!

--- a/gol.vgt.json
+++ b/gol.vgt.json
@@ -2,12 +2,16 @@
 
 , ["func", "to_vi", ["w", "x", "y", "offset"]
   , [
-      ["var", "yw"]
-    , ["set", "yw", ["*", "y", "w"]]
-
-    , ["var", "vi"] // vram index
-    , ["set", "vi", ["+", "yw", "x"]]
-    , ["set", "vi", ["+", "vi", "offset"]]
+      ["var", "vi"] // vram index
+    , ["set", "vi"
+      , ["+"
+        , ["+"
+            , ["*", "y", "w"]
+            , "x"
+          ]
+        , "offset"
+        ]
+      ]
 
     , ["return", "vi"]
     ]

ローカル変数 yw が不要になり、 vi への再代入もなくすことができました。

ライフゲームを実行すると……問題ないですね! よしよし。


次の箇所も入れ子の式にできます。

set文の 2番目の引数が配列だった場合は codegen_exp() を呼び出すようになっているため、 vgcg.rb は修正不要です。

--- a/gol.vgt.json
+++ b/gol.vgt.json
@@ -44,13 +44,11 @@
 , ["func", "adjust_index", ["width", "i"]
   , [
       ["var", "adjusted"]
-    , ["var", "max_i"]
-    , ["set", "max_i", ["+", "width", -1]]
 
     , ["case"
       , [["eq", "i", -1]
         , ["_cmt", "下限を超えた場合"]
-        , ["set", "adjusted", "max_i"]]
+        , ["set", "adjusted", ["+", "width", -1]]]
       , [["eq", "i", "width"]
         , ["_cmt", "上限を超えた場合"]
         , ["set", "adjusted", 0]]

いいですね。不要な一時変数がなくなって ちょっとすっきりしました。


えーとあとは…… あとは無理して入れ子の式に書き直さなくてもいいかな……。

あれだけ悩んだのに、結局ライフゲームのために必要だったのは この 2箇所くらいなので、 やっぱり余計な先回りをせずに 一時変数にセットするように手で書く選択で正解でしたね。


というわけで、入れ子の式が使えるようになりました!

codegen_exp()リファクタリングもやっておきたいですが、 長くなってきましたし、 ともかく入れ子の式を書けるようにするという目標は達成できましたので、 今回はここで切り上げます。

いやー、これでまた一段落ですね。年内に片付いてよかったよかった。

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

参考