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 を省略せずに書いています