これは LibreOffice Advent Calendar 2019 の 3日目の記事です!
※ 2019-12-03 に書いた記事をブログから引っ越してきました
TL;DR
- プログラムに入力として与えるデータの編集をどうするか問題
- 位置情報などはテキストで管理すると直感的に修正できなくて辛い
- LibreOffice Draw で編集して odg ファイルから情報を抜き出して使う方法を試してみたらいい感じだった
動機
-
プログラムに入力として与えるデータを用意したい
- ゲームのマップ、オブジェクトの配置など
- アルゴリズムや分析処理、作図ツールの検証に使うデータ
- etc.
-
ちょっとしたものならプログラム内に直接書いたりテキストデータとして用意したり
-
「ちょっとした」で済まなくなってくると辛い
- 位置情報
- 構造が複雑
- データが多い
-
どう辛いか
- 直感的に編集できない
- 一度2Dのグラフィックに変換しないと何がどうなっているのか分からない
- 配置、要素同士の位置関係、サイズ、オブジェクトの種類、属性、etc.
- 編集→表示させて確認→編集… を繰り返さないといけなくて手数が増えてめんどくさい
- 一度2Dのグラフィックに変換しないと何がどうなっているのか分からない
- 直感的に編集できない
-
こういう場合、WYSIWYG なエディタが欲しくなる
- 出来合いのツールがあればそれを使えばいいが、ない場合は……
- 自作する?
- GUI自作は大変
- コピペ、D&D、アンドゥ/リドゥ、ズーム表示
- 大変なので諦めてがんばりがち
- 適当な可視化ツールだけ作ってお茶を濁したりしがち
- エディタがあれば作業効率上がるはずなのに……コストが見合わない
- 特にすばやくプロトタイプを作りたい場合、手間をかけずにサッと使いたい
そこで、LibreOffice Draw を汎用エディタとして使えないか? と考えました。
矩形
さっそくやってみましょう。まずは基本ということで、矩形の位置とサイズを odg ファイルから抜き出してみます。
※ odg ファイルと書いてますが、以下では Flat XML な fodg ファイルを使います。odg でもだいたい同じだと思います。
Draw でこんな図形を描きます。
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 でこんな図を描きます。
ここから次のような情報が抜き出せれば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
いけますね。
応用編
コネクタを同じ箇所に複数つなげるとこのような見た目になります。
これ、矢印が重なると分かりにくいんですよね。この例でいえば、上から3番目のコネクタは両方向の矢印なのかな? とか、矢印が両方ともないコネクタもあるのかな? とか。
このように矢印がはっきり見えないと困るときや、コネクタの接続箇所の位置を調整したいとき、私はよくこういう描き方をします。
ちなみに、まとめて選択すれば一緒に移動できます。
この描き方を使ってさっきのコネクタの図を描き直してみました。今度はこの図から依存関係を抜き出してみましょう。
こういうのが抜き出せれば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 の一種)です。
要素は増えていますが、さっきの例と同じルールで描いているので、さっきのスクリプトで同じように抽出できるはず!
ここから抜き出した結果が下記です。
(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) 例: 回路図エディタ
そうそう、こういうのがやりたかったんですよ、という具体例。こういうの作りたいなーと思った時にサッと作れるようにしたかったのです。
こういう回路図を Draw で描いて、自作の論理回路シミュレータで読み込んで動かしてみました。見ての通りですが、使っているのは直線、矩形、矩形内のテキストだけです。
関連
The tool reads the content.xml and translates it into a suitable target language (usually C/++).
とのことで、似た方向性のようです。
せっかくのアドベントカレンダーですのでいくつか宣伝ぽく LibreOffice 関連記事へのリンクを貼ってみます。
- 2019-12-02 JavaでLibreOffice Calcのfodsファイルを読み書きするサンプル 2019
- 2019-12-02 JRubyでLibreOffice Calcのfodsファイルを読み書きするサンプル 2019
- 2019-11-03 LibreOffice Calcの入ったDockerイメージを作ってヘッドレスで動かす
- Docker べんり
- 2014-12-16 JavaScript(jrunscript/Rhino)でodsファイルからMySQLにデータ投入
- 5年前の 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
他に LibreOffice 関連で書いたもの
他に Ruby 関連で書いたもの