inkscapeに中央揃え機能を追加してみた

オープンソース描画ツール「inkscape」をいじって、中央揃え機能の追加を目指しました。

概要

 今回機能拡張を目指す「inkscape」は広く使われている描画ツールで、我々電気系の学徒も、回路図やその他の図を作成するときによく利用する。しかし、完璧なツールというものはなかなか有り得ないもので、このinkscapeもまた然りである。その一つとして、多くの描画ツールには、図形の中線同士を一致させる機能である中線機能が実装されているが、inkscapeにはそれが実装されていない、というものがある。
 そこで今回は、inkscapeに実装されていない中線機能の追加を目標に、この大規模ソフトウェアを手探った。

ビルド

 inkscapeの公式サイト(日本語版URL:https://inkscape.org/ja/)から、ソースファイルをダウンロードする方法が記載されている。
 inkscape開発チームでは、Bazzarという分散型バージョン管理システムを採用しており、Ubuntuのターミナル上で

$ bzr branch lp:inkscape

と入力すると、ソースファイルを入手できる。これを解凍し、ソースファイルのディレクトリ上で

$ CFLAGS="-O0 -g" ./configure --prefix=/home/hoge(ユーザーネーム)/inkscape_install(任意のファイル名)
$ make
$ make install

と入力して、ビルドする。-O0は最適化を最低レベルにする、-gは実行可能ファイルに「デバッガシンボル」を含める、というコンパイラのオプションであり、デバッグを行う上で必須というわけではないが、つけるのが無難である。子細な説明は諸所の解説ページや参考書に譲る。

調査

 ソフトの動作は、上記の方法でビルドしたのちに、Emacsでデバッガを起動して追跡した。

変更の方針

 中線機能を実現するには、まず図形を選択したときに、中線を表示する機能が必要であろう。また、二つの図形の端点や、中心を一致させる機能(スナップ機能)はすでに実装されているので、これを追跡して真似をすれば、中線機能実装に近づくのではないかという考えに至った。
 そこで、

 ①中線を表示するために、図形やその枠線をどのようにウィンドウ上に表示しているのかを追跡
 ②追跡した箇所を真似して、中線を表示
 ③他のスナップ機能の動きを追跡
 ④追跡した箇所を真似して、中線機能を実装

のような手順で中線機能の実装を目指すこととした。要は、自分たちで一から機能を拡張することはなかなか難しいので、既存の機能を真似することにより、実装を目指そう、ということである。
 また、これは後々気付いたことであるが、どんな機能もOn/Offの切り替えができないと、非常に不便であるので、やはりこれも既存のツールボタン周りの動作を追跡して、真似をすることにより実装することを目指した。
 

中線の表示

 中線の表示は、バウンディングボックス(オブジェクトを囲む破線の長方形)を表示する処理に追加する形で実装するのが楽なのではないかと考え、まずバウンディングボックスを描画する処理を行っている部分を探した。最初は適当にブレークポイントを貼ってコードを追いかけていたが、なかなか見つからず進捗が得られなかった。そこで、コードを追っている途中でbboxという単語が散見されたので、grepで"bbox"という単語で検索し、ヒットしたところを見ていくことにした。すると、src/display/sodipodi-ctrlrect.cpp中のrender関数がバウンディングボックスの描画、update関数がバウンディングボックス周囲の画面の更新を担当していることが分かった。そこで、render関数のバウンディングボックスを描画する処理に

cairo_move_to(buf->ct, area[X].min()-1000, (area[Y].min()+area[Y].max())/2);
cairo_line_to(buf->ct, area[X].max()+1000, (area[Y].min()+area[Y].max())/2);
cairo_move_to(buf->ct, (area[X].min()+area[X].max())/2, area[Y].min()-1000);
cairo_line_to(buf->ct, (area[X].min()+area[X].max())/2, area[Y].max()+1000);

と追加した。何をしたか説明すると

  • バウンディングボックスの描画がcairoを使っていたのでそれに習った
  • 1行目から2行目で中線のうち水平な方を描画
  • 3行目から4行目で中線のうち鉛直な方を描画

すると、一応中線らしきものは表示されたが、きちんと全体が表示されずバウンディングボックスと交わる部分しか表示されなかった。おそらく画面の更新がきちんと行われていないのが原因だと思い、update関数の更新すべき画面の範囲を決める部分に、

//水平な方の中線(移動前)
canvas->requestRedraw(area_old[X].min() - 1001, (area_old[Y].min() + area_old[Y].max())/2 - 1,
                      area_old[X].max() + 1001, (area_old[Y].min() + area_old[Y].max())/2 + 1);

//水平な方の中線(移動後)
canvas->requestRedraw(area[X].min() - 1001, (area[Y].min()+area[Y].max())/2 - 1,
                      area[X].max() + 1001, (area[Y].min()+area[Y].max())/2 + 1);

//垂直な方の中線(移動前)
canvas->requestRedraw((area_old[X].min()+area_old[X].max())/2 - 1, area_old[Y].min() - 1001,
                      (area_old[X].min()+area_old[X].max())/2 + 1, area_old[Y].max() + 1001);

//垂直な方の中線(移動前)
canvas->requestRedraw((area[X].min()+area[X].max())/2 - 1, area[Y].min() - 1001,
                      (area[X].min()+area[X].max())/2 + 1, area[Y].max() + 1001);

と追加し、各中線を囲む長方形領域を更新するようにした。
さらに、update関数のバウンディングボックスの範囲を決めていると思われる部分を

        x1 = area[X].min() - 1;
        y1 = area[Y].min() - 1;
        x2 = area[X].max() + _shadow_size + 1;
        y2 = area[Y].max() + _shadow_size + 1;

から

        x1 = area[X].min() - 1001;
        y1 = area[Y].min() - 1001;
        x2 = area[X].max() + _shadow_size + 1001;
        y2 = area[Y].max() + _shadow_size + 1001;

に拡張することで中線の周囲も画面を更新するようにした。これで中線のうち水平な方はきちんと表示されるようになったが、垂直な方がなぜか短く表示されてしまう問題が発生した。はっきりとした原因は最後まで分からなかったが、もともと長方形を描画するだけの処理に非常に長い線を描く処理を追加しているので、どこかのif文で弾かれているのだろうと思い、いろいろな場所のif文を外してコンパイルしてみたところ、render関数の

if ( area_w_shadow.intersects(buf->rect) )

というif文を外したときにうまく表示されるようになり、ひとまず中線の表示は成功した。が、実際にコンパイルしてinkscapeを起動してみると、最初に表示されるページ(紙みたいなやつ)にも中線が表示されてしまった。これはページを表示する処理がバウンディングボックスを表示する処理と同じ部分で行われていたためとすぐに想像がついたので、先ほど追加した中線を描画する処理を

if (_dashed){
}

で囲うことで、破線の時だけ(バウンディングボックスのときだけ)中線を描画するようにして解決した。

中線へのスナップ機能の追加

 もともとinkscapeにはバウンディングの角と角をスナップする機能や、中心と中心をスナップする機能は備わっているのでそれらの処理に追加する形で中線同士を合わせるようにスナップする機能を実装することにした。まずはそれらの機能を担当している部分を探すことにして、src/snap.cppといういかにもなファイルの色々な部分にブレークポイントを貼って処理を追ってみたところ、src/object-snapper.cppの_snapNodes関数がスナップ先の候補点を作成していることが分かった。そこで、その候補点を通る水平線および鉛直線に、スナップ元の点から垂線をおろした点も新たにスナップ先の候補点とすればうまくいくのではないかと思い、_snapNodes関数に

Geom::Point const p1 = target_pt; 
Geom::Point p2 = p1;
Geom::Point p3 = p1;
            
p2[0] += 1;
p3[1] += 1;
Geom::Point const p_proj1 = Geom::projection(p.getPoint(), Geom::Line(p1, p2));
Geom::Point const p_proj2 = Geom::projection(p.getPoint(), Geom::Line(p1, p3));
dist = Geom::L2(p_proj1 - p.getPoint());
if (dist < getSnapperTolerance() && dist < s.getSnapDistance()) {
    s = SnappedPoint(p_proj1, p.getSourceType(), p.getSourceNum(), (*k).getTargetType(), dist, getSnapperTolerance(), getSnapperAlwaysSnap(), false, true, (*k).getTargetBBox());
    success = true;
}
                
dist = Geom::L2(p_proj2 - p.getPoint());
if (dist < getSnapperTolerance() && dist < s.getSnapDistance()) {
    s = SnappedPoint(p_proj2, p.getSourceType(), p.getSourceNum(), (*k).getTargetType(), dist, getSnapperTolerance(),
    success = true;
}

と追加した。ここで、

  • p1はもともとのスナップ先
  • p2はp1よりx座標が1だけ大きい点
  • p3はp1よりy座標が1だけ大きい点
  • p_proj1はスナップ元からp1,p2を結んだ直線に下ろした垂線の足
  • p_proj2はスナップ元からp1,p3を結んだ直線に下ろした垂線の足

すると、バウンディングの中心と中心をスナップする機能をオンにすれば一応中線同士を合わせるようにスナップするようになった。

しかし、これだけではオブジェクト同士を十分に近づけなければスナップしないという問題が発生した。さらにコードを追いかけて調べたところ、_snapNodes関数の前に呼ばれていた_findCandidates関数がスナップ先の候補点の対象となるオブジェクトを決めており、そこでスナップ元の点と一定以上近いオブジェクト上の点しかそもそもスナップ先の候補に選ばれないことが分かった。ここまでで分かったスナップ機能の動作は

  • _findCandidates関数がスナップ元の近くにあるオブジェクトを選ぶ
  • snapNodes関数がそのオブジェクト上のうちボタンで指定された点(中心、角など)を選ぶ
  • スナップ先候補のうちスナップ元と一番近い点にスナップ

という流れである。そこで、_findCandidates関数の

if (bbox_to_snap_incl.intersects(*bbox_of_item) 
    || (_snapmanager->snapprefs.isTargetSnappable(SNAPTARGET_ROTATION_CENTER) 
    && bbox_to_snap_incl.contains(item->getCenter())))

このif文を外し、すべてのオブジェクトをスナップ対象にしたらオブジェクト同士が離れていてもきちんとスナップするようになった。

ボタンの追加

既存のスナップ機能はボタンでオン・オフできるので、今回追加した中線スナップ機能もボタンでオン・オフできるようにしようと思い、既存のボタンに習って新しいボタンを追加することにした。そこで、既存の"境界枠の中点にスナップ"ボタンに関連するワード"midpoint"でgrep検索してボタンに関連するソースを見つけ、見つかったソースに片っ端から既存のボタンと同じようにコードを追加してとりあえずボタンを増やそうとした。ファイルが多いのでコードは割愛するが、以下のファイルにコードを追加した。

snap-enums.h
attributes.h
attributes.cpp
attributes-test.h
toolbox.cpp
icon.cpp
sp-namedview.cpp

すると、ボタンが表示されるようになったが、アイコンを作っていないので見た目がおかしくなった。そこで、アイコンが書かれている

share/icons/icons.svg

テキストエディタで開いて新しいアイコンを追加したところ、うまく表示されるようになった。次に実際にボタンで中線スナップ機能をオン・オフできるようにするため、src/snap-prefernces.cppのsource2target関数に

case SNAPSOURCE_BBOX_EDGE_LINE:
            return SNAPTARGET_BBOX_EDGE_LINE;

と追加してボタンが押された時にフラグが立つようにした。さらに、src/object-snapper.cppの_snapNodes関数の中線スナップ機能を追加した部分を、

if(_snapmanager->snapprefs.isTargetSnappable(SNAPTARGET_BBOX_EDGE_LINE)){
}

で囲うことでボタンが押されたときだけ機能がオンになるようにした。また、今回追加した機能の説明文を日本語で表示されるように、日本語化ファイルpo/ja.poに、

msgid "Snap Lines of bounding box edges"
msgstr "選択したスナップ点を通る水平線と鉛直線にスナップ"

と追加し、説明文を日本語にした。