2021-03-24に更新

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

読了目安:11分

これは LibreOffice Advent Calendar 2019 の 3日目の記事です!
(※ 2019-12-03に書いた記事のクロス投稿です)

TL;DR

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

動機

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

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

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

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

矩形

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

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

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

image

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 を使ってスクリプトを書きます(Ruby に馴染みのない方のためにここだけ return を省略せずに書いています)。

# 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 でこんな図を描きます。

image

ここからこういう情報が抜き出せれば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

いけますね。

応用編

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

image

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

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

image

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

image

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

image

こういうのが抜き出せれば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 の一種)です。

image

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

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

(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_ブレンダーを閉める

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


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

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

その他の図形

(追記 2020-05-09) 例: 回路図エディタ

リレー式論理回路シミュレータを自作して1bit CPUまで動かした

そうそう、こういうのがやりたかったんですよ、という具体例。こういうの作りたいなーと思った時にサッと作れるようにしたかったのです。

image

こういう回路図を Draw で描いて、自作の論理回路シミュレータで読み込んで動かしてみました。 見ての通りですが、使っているのは直線、矩形、矩形内のテキストだけです。

関連

せっかくのアドベントカレンダーですのでいくつか宣伝ぽく 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

Originally published at qiita.com
ツイッターでシェア
みんなに共有、忘れないようにメモ

sonota486

Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

有料記事を販売できるようになりました!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?

コメント