inkscapeで全レイヤー間で同期して動く図形を追加した話

この記事は、東京大学工学部電子情報工学科・電気電子工学科3年生後期実験、大規模ソフトウェアを手探るにおけるレポートとして書かれたものです。

TL;DR

  • inkscapeというOSSのドローソフトに新しく以下の機能を追加した
    • 図形を一度に全レイヤーにコピーする
    • 移動、拡大等の変更をコピーされた複数の図形全てに反映させる

はじめに

大規模ソフトウェアを手探るという実験は2,3人でチームを組み、そのチーム単位で計10日間という期間の中で、選んだOSSに新しく機能を追加することを目標としているものです。

私たちはOSSのドローソフトであるinkscapeを題材として選択し、機能拡張を目指しました。

追加する機能の候補としては単一の図形を全レイヤーに所属させることを第一目標としました。しかし、紆余曲折を経て、最終的な到達点としては、既存の図形を全レイヤーの数だけコピーし、ある図形への変更を、同期的にコピーされたすべての図形に反映させることになりました。

以下、この期間で私たちが取り組んだ成果のまとめを記します。

環境

Ubuntu18.04

ビルドについて

変更を加える大元のソースファイルは以下からクローンして入手しました。 gitlab.com

実際のビルド手順はクローンしたファイル内のCONTRIBUTING.mdに書かれている手順に従って行いました。

しかし、手順通りではうまくいかなかった点がいくつかあったので、それに関して説明します。

まず、cmakeを実行しようとすると色々ライブラリがないと怒られるので

sudo apt-get build-dep inkscape

を実行すると必要なライブラリが入手できます。

しかし、実行しようとするとそもそもそのようなライブラリがないと怒られる場合があると思います。

そのときは/etc/apt/sources.list にある、

deb-src http://jp.archive.ubuntu.com/ubuntu/ bionic universe

のコメントを外すとライブラリがインストールできます。

実はこれだけでは不十分で、さらにライブラリが不足していると怒られますが、その他に必要なライブラリは数個なのでその都度インストールすると無事ビルドできます。

手探る

無事ビルドが終わったので、inkscapeがどのように動作しているかについて調べよう、となったのですが、ファイルが巨大すぎるため、どのようにソースコードを追っていくべきかという問題に直面しました。

今回は

という3つのツールを用いてソースコードを解析することを試みました。

gdbデバッグを行うツール、grepはファイル内検索等に用いるツールであり、比較的有名だと思われるので、これらの具体的な機能の説明はここでは行わず、uftraceについてのみ少し説明します。

uftraceについて

uftraceとは、あるプログラムの実行中に呼ばれた関数についてその呼び出し回数や、呼び出しの順番などを記録するツールです。

このツールを用いることで、例えばある図形を拡大する際にどのような関数が用いられているかを特定することができます。さらに、その関数をgdbブレークポイントに設定することで、その関数が呼び出された前後における変数の様子を調べることができるため、非常にソースコードの解析が行いやすくなります。

uftraceのインストール方法や使用方法、実際の使用例は以下の記事で分かりやすく説明されています。

namopa.hatenablog.com

inkscapeは大規模なので線を引くという動作だけでも1000個ほど関数が呼び出されます。

したがってuftrace replayとただ実行するだけではをあまり効果を発揮しません。

解決策の1つとしてuftraceには-tオプションをつけることができ、このオプションを利用する方法があります。これを用いてuftrace replay -t 1msのように実行することで実行時間が1ms以下の関数を表示せず、大事だと思われる関数とそれらの関数がどのように呼び出されたか、という情報のみを抽出することができます。

呼び出される関数が少なすぎる場合や多すぎる場合は1msの部分を変更すると良いです。

inkscapeの構造

少し話が逸れたので元に戻します。先程のツールを使用してinkscapeの構造を解析すると以下のことが分かりました。

1.図形とレイヤーの関係
  • 図形とレイヤーが所属するクラスはどちらも共通のクラスSPObjectを継承しており、このクラスは1つの親と複数の子(これらはすべてSPObjectを継承)を持ち、相互に結びついてる。

  • 例えばレイヤー1の上に星型の図形が存在している状況を、inkscapeのデータ構造的にはレイヤー1を親ノードとし、さらにその子ノードとして星形の図形が存在しているというように実装している。

以下に関係を簡単な図として表現したものを載せておきます。

f:id:dried_doss:20191103001636j:plain

この情報を得たタイミングで、課題設定の変更を行いました。

分かったこととして、

  • 単一の図形は親を1つしか持てないため、複数のレイヤーに所属することは構造を大きく変えない限りは不可能である。
  • しかし構造を大きく変えるほどの時間はない。

があり、全レイヤーに図形を表示させるためには、素直に全レイヤーの個数分だけ図形が必要であることが分かりました。

したがって図形を単一のものではなく、まず全レイヤーの分だけ作ってしまおうという発想に至り、方針の変更を行いました。

2.動作の仕組みについて

実際に関数を作り始めようというあたりで、関数を作ったとしても、その関数をソースファイルのどの部分に追加してあげればよいのかという疑問が生じました。

それについて調べるためにまず既存の動作がどのように呼び出されているか、について調べることにしました。

例えば、ある図形をコピーする場合はどのように関数が呼び出されているかについて見ていくことにします。

調べていくと、動作の呼び出しには主に2つのファイルが関係していることが分かりました。

  • src/verbs.h
  • src/verbs.cpp

verbs.hでは

enum {
    /* Header */
    SP_VERB_INVALID, /**< A dummy verb to represent doing something wrong. */
    SP_VERB_NONE,    /**< A dummy verb to represent not having a verb. */
    /* File */
    SP_VERB_FILE_NEW,           /**< A new file in a new window. */
    SP_VERB_FILE_OPEN,          /**< Open a file. */
    ...

のように列挙型(enum)として動作のIDが定められていると分かります。

このenum型の中身を見ていくと、図形のコピーをする動作のIDはどうやら、SP_VERB_EDIT_COPYとして与えられているようだ、ということが分かります。

しかし、これはまだ実体が定義されておらず、ただIDが定義されているのみです。実体に関してはverbs.cppに記述されているようなのでその様子を見てみることにしました。

verbs.cppの中身をざっくり見てみるとcase文を用いて先程の列挙型で定義したIDを場合分けしていることが分かります。実際の様子は以下に示すようになっています。

void EditVerb::perform(SPAction *action, void *data)
{
   ...
 switch (reinterpret_cast<std::size_t>(data)) {
        case SP_VERB_EDIT_UNDO:
            sp_undo(dt, dt->getDocument());
            break;
        case SP_VERB_EDIT_REDO:
            sp_redo(dt, dt->getDocument());
            break;
        case SP_VERB_EDIT_CUT:
            dt->selection->cut();
            break;
        case SP_VERB_EDIT_COPY:
            dt->selection->copy();
            break;
        case SP_VERB_EDIT_PASTE:
            sp_selection_paste(dt, false);
            break;
  ...

また、これらは各クラスのメンバ関数として定義されているので、インスタンス生成のタイミングを探ってみると、これもまたverbs.cppの中で、以下のように記述されていました。

Verb *Verb::_base_verbs[] = {
    ...
    new EditVerb(SP_VERB_EDIT_COPY, "EditCopy", N_("_Copy"), N_("Copy selection to clipboard"),
                 INKSCAPE_ICON("edit-copy")),
    new EditVerb(SP_VERB_EDIT_PASTE, "EditPaste", N_("_Paste"),
                 N_("Paste objects from clipboard to mouse point, or paste text"), INKSCAPE_ICON("edit-paste")),
  ...

ここまで見ると新しく動作を追加する方法がなんとなく分かってきます。

つまり、次の順序で実装すればいいことになります。

  1. src/verbs.hに動作のIDを追加する。
  2. src/verbs.cppにcase文を追加し、呼び出す関数を設定する。
  3. 同じくverbs.cppにインスタンス生成の記述をする。

ただしここには1つ落とし穴があり、インスタンス生成を記述するコードの直前でソースコード内に以下のコメントがあります。

// these must be in the same order as the SP_VERB_* enum in "verbs.h"

つまり、enum型に記述する動作IDの順番と、インスタンス生成の記述をする順番は同じにしてください、ということです。見落としがちなので注意しましょう。

ここまで終わると新しく動作が追加できたことになるので、あとはユーザーがこれらの動作を主体的に行うことができるようにする方法を探っていきます。ユーザーが動作を指定するタイミングはいくつかあるので以下に例を示しておきます。

  • メニューバー

f:id:dried_doss:20191104122910p:plain

f:id:dried_doss:20191104122937p:plain

f:id:dried_doss:20191104123117p:plain

今回は全レイヤーにコピーを行うことを目標としているため、よく似た動作であるコピーも存在している、コンテキストメニューを変更していきます。

コンテキストメニューの記述はソース内では

  • src/ui/contextmenu.cpp

に記述されているので、実際に見ていきます。

ContextMenu::ContextMenu(SPDesktop *desktop, SPItem *item) :
    _item(item),
    MIGroup(),
    MIParent(_("Go to parent"))
{
   ...
    AppendItemFromVerb(Inkscape::Verb::get(SP_VERB_EDIT_CUT), show_icons);
    AppendItemFromVerb(Inkscape::Verb::get(SP_VERB_EDIT_COPY), show_icons);
   ...

このようにAppendItemFromVerbという関数を用いて先程見てきた動作IDを追加していることが分かります。

動作を新しく追加する場合は、引数の動作IDの部分だけ変更して関数を追加してあげると良いです。

これで作成した関数をユーザーが選択できるまでの流れが分かったので、あとは実際の関数を作成することのみを考えれば良いです。

実装方法

次に、図形が全レイヤーに表示されるようにするための関数を作成していきます。 やるべきことは大まかに

  1. 選択した図形と同じ図形を全てのレイヤー上に複製する
  2. ただ複製しただけだと移動や色変更などの操作がどれか1つの図形にしか適用されないので、操作が全ての図形に一斉に反映されるようにする

の2つになります。 実際には複数個の図形が重なっているけれども、あたかも1つの図形のように振る舞わせたいということです。

2番目の内容は具体性に欠けるので詳細を記しておくと

複製した図形同士を連結リストのようにポインタで結びつけ、いずれかの図形に変更が施された場合はポインタを辿って他の図形にも同じ変更を行うというふうにしました。 (他にも実現方法はあると思います)

図形クラスの特定

何をするにしてもまずは図形を取り扱うためのファイルを見つけ出す必要があります。

探したところ、src/object 内に四角形や円などの図形について書かれたファイルがおかれていることが分かりました。

そのうちの1つであるsp-rect.hのクラス定義を覗いてみると

class SPRect : public SPShape {
public:
          ...

のようにSPShapeクラスを継承して作られていることがわかります。他の図形に関するクラスの継承関係を見てもやはり共通してSPShapeを親に持っています。(間にSPPolygonなどを挟む場合もありますが、最終的にはSPShapeにたどり着きます)

なので親クラスに変更を施せば個々の図形に対してもその変更が適用されるし、自分で新しくクラス等を作るよりも労力が少なく済みます。

ここでSPShapeクラスよりもSPShapeの親を更に辿っていくと見つかるSPItemクラスの方がInkscape内で広く使われており関数や処理などの流用が利くので、こちらをいじることにしました。

SPItemクラスへの変更

まずはヘッダファイルのsrc/object/sp-item.hに変更を施していきます。

class SPItem : public SPObject {
public:
         ...
    bool is_all_layer_figure_flag;
    SPItem *sub_parent;
    SPItem *sub_child;
    void createAllLayerFigure();
    void revertAllLayerFigure();        

メンバに上記のものを追加します。

is_all_layer_figure_flagは各処理でその図形が全レイヤー化されているかどうかを確認する必要が出てくるのでそのためのフラグ。

sub_parentsub_childは先述の連結リスト構造を作るためのポインタです。

createAllLayerFigure()は図形を全レイヤー化させる本命の関数です。

revertAllLayerFigure()は全レイヤー化された図形を元の1つだけの図形に戻す関数です。移動などの処理の途中でこの機能があると都合がよかったので作りました。


変数と関数を追加したのでコンストラクタで初期化されるようにしておきます。

SPItem::SPItem() : SPObject() {
   ...
    is_all_layer_figure_flag = false;
    sub_parent = nullptr;
    sub_child = nullptr;
図形を生成している場所を探す

ある図形を他のレイヤーにコピーする処理を1から自分で書くのは当然現実的ではないので、図形の生成を行っている関数を探してそこの処理を流用する方針でいきました。

目星をつけたのはsrcに直置きしてあるseltrans.cppです。

このファイル内には選択した図形に対する移動や拡大・縮小などの処理が書かれていますが、その中にstampという関数が存在します。

stampはおそらくコピペで図形を複製する際に用いられる関数だと思います。ここの処理を真似するのが良さそうです

void Inkscape::SelTrans::stamp()
{
    Inkscape::Selection *selection = _desktop->getSelection();

    ...

    /* stamping mode */
    if (!_empty) {

        ...

        for(std::vector<SPItem*>::const_iterator x=l.begin();x!=l.end(); ++x) {
            SPItem *original_item = *x;
            Inkscape::XML::Node *original_repr = original_item->getRepr();

            // remember the position of the item
            gint pos = original_repr->position();
            // remember parent
            Inkscape::XML::Node *parent = original_repr->parent();

            Inkscape::XML::Node *copy_repr = original_repr->duplicate(parent->document());

            // add the new repr to the parent
            parent->appendChild(copy_repr);
            // move to the saved position
            copy_repr->setPosition(pos > 0 ? pos : 0);

            SPItem *copy_item = (SPItem *) _desktop->getDocument()->getObjectByRepr(copy_repr);

            Geom::Affine const *new_affine;
            if (_show == SHOW_OUTLINE) {
                Geom::Affine const i2d(original_item->i2dt_affine());
                Geom::Affine const i2dnew( i2d * _current_relative_affine );
                copy_item->set_i2d_affine(i2dnew);
                new_affine = &copy_item->transform;
            } else {
                new_affine = &original_item->transform;
            }

            copy_item->doWriteTransform(*new_affine);

            if ( copy_item->isCenterSet() && _center ) {
                copy_item->setCenter(*_center * _current_relative_affine);
            }
            Inkscape::GC::release(copy_repr);
            ...
}

※あまり重要でない(と思われる)部分は省いています

上の処理をみるとSPItem型のインスタンスの他に、original_reprcopy_reprといったreprという変数名が出てきます。Inkscape上の図形はSPItemの子クラスによる記述の他に、このreprによっても管理されているのだと思います。

また

SPItem *copy_item = (SPItem *) _desktop->getDocument()->getObjectByRepr(copy_repr);

から分かるようにreprからSPItemを生成することが可能であり、両者が密接に関連していることが伺えます。

詳細は追いきれていないのでなんとも言えませんが、とにかく図形に手を加えるときはSPItemInkscape::XML::Node型のreprの両方を考慮する必要があります。

他の部分も見ていくと、まずreprはそれ自身がもつduplicate関数によって複製が可能です。

parent->appendChild(copy_repr);parentoriginal_repr->parent();によって取得されたコピー元の図形が所属するレイヤーであり、この一文で図形とレイヤーを紐付けています。

Geom::Affine const *new_affine;以下のaffine絡みの記述は図形の座標に関するものです。

大まかな働きが分かったところでこれらを元に関数を作成していきます

関数の実装

まずcreateAllLayerFigure関数の中身を以下のようにしました。

void SPItem::createAllLayerFigure(){
    //既に全レイヤー化されているなら何もしない
    if(this->is_all_layer_figure_flag == true){return;}
    
    SPDesktop *_desktop = SP_ACTIVE_DESKTOP;
    Inkscape::XML::Node *my_repr = this->getRepr();
    
    //存在するレイヤーを取得
    std::vector <SPObject *> my_layers = _desktop->doc()->getResourceList("layer");

    gint my_pos = my_repr->position();

    Inkscape::XML::Node *my_parent = my_repr->parent();
    SPObject *my_current_layer = this->parent;

    Inkscape::XML::Node *my_copy_repr;
    SPItem *my_copy_item;
    Geom::Affine *my_copy_affine;
    SPLPEItem *my_lpeitem;

    SPItem *my_prev_item = this;
    this->is_all_layer_figure_flag = true;

    //存在するレイヤーの分だけループを回し、図形を複製
    for (std::vector<SPObject *>::const_iterator iter = my_layers.begin(); iter != my_layers.end(); ++iter){
        if(*iter == my_current_layer){
            continue;
        }
        my_copy_repr = my_repr->duplicate(my_parent->document());
        (*iter)->appendChildRepr(my_copy_repr);
        my_copy_repr->setPosition(my_pos > 0 ? my_pos : 0);
        my_copy_item = (SPItem *)_desktop->getDocument()->getObjectByRepr(my_copy_repr);
        my_copy_affine = &this->transform;
        my_copy_item->doWriteTransform(*my_copy_affine);

        //新たに作成した図形のフラグを有効にし、ポインタをつなげておく
        my_copy_item->is_all_layer_figure_flag = true;
        my_copy_item->sub_parent = my_prev_item;
        my_copy_item->sub_parent->sub_child = my_copy_item;
        
        Inkscape::GC::release(*my_copy_repr);

        my_prev_item = my_copy_item;
    }

}

基本となる部分はstampと同様です。

ただstampでは選択した図形が所属するレイヤーだけ考慮すればよかったのですが、今回は存在する全てのレイヤーの情報を取って来なければなりません。

これはstd::vector <SPObject *> my_layers = _desktop->doc()->getResourceList("layer");とすることで存在するレイヤーをvectorに格納できます。

あとはイテレータでforループを回してそれぞれのレイヤーに図形をコピーしていきます。


次にrevertAllLayerFigureを作成します。

全レイヤー図形を元の1つにもどすには図形の削除が行われる場所の特定が必要です。

調べたところInkscapeでdeleteキーによって図形を削除したとき、最初にsrcselection-chemistry.cppにあるdeleteItems()が呼ばれ、さらにその中でsp_selection_delete_impldeleteObjectと順に呼ばれていることが分かりました。

最後のdeleteObjectSPItemの親クラスSPObjectがもつ関数です。子クラスとなるSPItemでも使えるのでこれを利用します。

//全レイヤー化された図形をもとに戻す
void SPItem::revertAllLayerFigure(){
    //全レイヤー化されていないなら何もしない
    if(this->is_all_layer_figure_flag == false){return;}
    
    SPObject *selected_layer;
    SPItem *my_item_delete = this->sub_parent;
    SPItem *my_item_next;

    //ポインタを辿って図形を消す
    while(my_item_delete != nullptr){
        selected_layer = my_item_delete->parent;
        my_item_next = my_item_delete->sub_parent;
        my_item_delete->deleteObject(true,true);
        selected_layer->requestDisplayUpdate(SP_OBJECT_MODIFIED_FLAG);
        my_item_delete = my_item_next; 
    }
    my_item_delete = this->sub_child;
    while(my_item_delete != nullptr){
        selected_layer = my_item_delete->parent;
        my_item_next = my_item_delete->sub_child;
        my_item_delete->deleteObject(true,true);
        selected_layer->requestDisplayUpdate(SP_OBJECT_MODIFIED_FLAG);
        my_item_delete = my_item_next;
    }

    //フラグ・ポインタを元に戻す
    this->sub_child = nullptr;
    this->sub_parent = nullptr;
    this->is_all_layer_figure_flag = false;
}

また図形の削除について探っているうちに、全レイヤー化した図形を選択した状態でdeleteで削除すると一番上の図形だけ消えてしまい、まずい状態になることに気づきました。

連結リストの中のあるノードをポインタの繋ぎ変えを行わずに消去してしまうのと同じことをしており、セグフォの原因となってしまうからです。(そうでなくてもdeleteで一番上しか消えないというのは仕様として良くないですし)

なのでselection-chemistry.cppsp_selection_delete_implを編集してdeleteキーで全ての図形が一斉に消えるようにしました。

static void sp_selection_delete_impl(std::vector<SPItem*> const &items, bool propagate = true, bool propagate_descendants = true)
{
              ...
    for (auto item : items) {

        //変更部分
        if(item->is_all_layer_figure_flag == true){
            item->revertAllLayerFigure();
        }
        //ここまで
   
        item->deleteObject(propagate, propagate_descendants);
       ...

削除対象が全レイヤー図形だったらrevertで1つに戻してから残った1つを通常と同じ処理で消しています。

変更が全ての図形に反映されるようにする

図形が全レイヤーに表示されるようにはなりましたが、このままだと何か変更をしようとしても一番上のレイヤーにある図形にしか適用されません。

これを解決するため当初は変更が行われるタイミングでポインタにより結ばれている他の図形オブジェクトに対しても同じ変更を施す、ということを考えていました。

しかし色々と試してみた結果、変更する直前に図形を一旦1つに戻してその図形のみに変更を適用し、変更が終わったら再びcreateAllLayerFigure関数で図形を複製するという方式のほうが比較的楽に実現できそうだったのでこちらを採用しました。(revert関数を用意した理由です)


図形の移動・拡大・色変更などの処理は当然別々の関数で行われており、1つ1つ探していくのは骨が折れます。

しかし最終的に変更を画面に反映される関数は共通で、src/object/sp-object.cppに存在するrequestDisplayUpdateが行っています。

この関数にブレークポイントを貼てInkscape上で操作をし、止まったらそこからバックトレースでログを見ることで容易に各種変更に対応する関数を特定することができます。


移動や拡大といった幾何的な操作は個別の関数で受理されたのち、最終的にsrcseltrans.cppにあるtransform関数に送られて変更を反映するようになっています。

よってここのコードをいじくればよいです。

void Inkscape::SelTrans::transform(Geom::Affine const &rel_affine, Geom::Point const &norm)
{
             ...
    Geom::Affine const affine( Geom::Translate(-norm) * rel_affine * Geom::Translate(norm) );
    if (_show == SHOW_CONTENT) {
        // update the content
        for (unsigned i = 0; i < _items.size(); i++) {
            SPItem &item = *_items[i];
            if( SP_IS_ROOT(&item) ) {
                _desktop->messageStack()->flash(Inkscape::WARNING_MESSAGE, _("Cannot transform an embedded SVG."));
                break;
            }
            Geom::Affine const &prev_transform = _items_affines[i];

            //変更箇所
            if(item.is_all_layer_figure_flag == false){
                item.set_i2d_affine(prev_transform * affine);    
            }else{
                item.revertAllLayerFigure();
                item.set_i2d_affine(prev_transform * affine);
                item.createAllLayerFigure();
            }
            //ここまで
            ...

itemが呼んでいるset_i2d_affineは先程のrequestDisplayUpdateに繋がる関数です。幾何的な変更をアフィンで計算して引数に渡し、それを元に描画する図形の形状や位置を決定しているのでしょう。

この関数の前後をrevertとcreateで挟めば先程の「一旦もどして、変更後に再度複製」という処理を行えます。


色などの変更はSPItemクラスが持つset関数が行っています。こちらも同様に中身をrevertとcreateで挟みます。

void SPItem::set(SPAttributeEnum key, gchar const* value) {
    SPItem *item = this;
    SPItem* object = item;

    //図形をrevertで1つにする
    //後で復元するためにフラグを有効にする
    bool my_flag = false;
    if(this->is_all_layer_figure_flag == true){
        my_flag = true;
        this->revertAllLayerFigure();
    }
    //

    switch (key) {
        case SP_ATTR_TRANSFORM: {
            ...
            }
            break;
        }
        case SP_PROP_CLIP_PATH: {
            ...
            }
            ...
    }

    //createで復元
    if(my_flag == true){
        my_flag = false;
        this->createAllLayerFigure();
    }
    //
}

transformと違って様々な処理をswitch文で分岐しているので、数行をrevertとcreateで挟むというわけにはいきません。

my_flagというフラグを用意してrevertを呼んだという情報を保持しておき、switch文を文を抜けたらフラグを参照してcreateするか判断するというようにしました。



これで変更が全ての図形に対して反映されるようになりました

不具合

Inkscapeで図形が生成されるプロセスを完全には掴みきれていないので、create関数で複製された図形は不完全なものになっている可能性が高い。

とりあえず右クリックから図形を全レイヤー化させたり、掴んであちこち動かしたりといった程度のことをしている分には問題なく動作するが、それ以外のInkscapeの機能を使ったときの挙動は十分に確認できていない。

(おそらく何かしらのバグが発生すると思われる)

感想

  • 大学の授業等でこれまで扱ってきたコードというのはせいぜい数百行で、ソースコードの全体を把握することは容易でした。しかし、今回扱ったような大規模なソフトウェアでは全体を把握することはまず不可能です。それでもなお、その中で解析を行わなくてはならないという状況だったため、新たな知識が自然と要求されることも多く、学びの多いものであり、大変有意義な実験でした。