Rust から ImageMagick を呼んで Slack の emoji に影をつける

Slack Emoji Darkmode Shader

Slack Workspace に登録された emoji を落としてきて白い影をつけてローカルに保存するだけのツール。

id:hitode909 さんのこちらの記事 に触発されて作った Rust 製 CLI の習作です。

Rust で何か作りたいなと思っていたところにちょうど CLI 向きの題材で、画像変換部分は ImageMagick に投げるだけとはいえ FFI を使う制約上 unsafe 体験もできるし楽しそうと思って作り始めました。

まだ crates.io に公開したりできるような代物ではないのですが最低限動くようになったので中間記録をまとめます。

ImageMagick

https://imagemagick.org/

言わずと知れた ImageMagick

実は C から使える MagickWand という API が提供されている (ほかにも様々な言語を対象として API が提供されています1。)

rust-bindgen

https://github.com/rust-lang/rust-bindgen

https://docs.rs/bindgen/0.53.2/bindgen/

Rust から C のライブラリへの FFI を自動で生成してくれるすごいやつ。 こいつに MagickWand.h を食わせれば今回やりたいことの半分はできてしまう。 Users Guide の 3 章を読めば使い方がだいたいわかると思う。

...と言いたいのですが build.rs を書くのに少しノウハウが必要だと思います。 build.rs は cargo build 時に他のコードよりも前にコンパイル & 実行されるスクリプトで、The Cargo Book 3.8 章 に詳しい解説が書かれているのでそれをよく読めばだいぶ勘がつかめます。

とはいえそこに書かれていない非自明な点もあり、例えば MagickWand.h を食わせると言っても MagickWand.h 内で include されている他のヘッダファイルの場所はコンパイラはよく知らないわけです。

そこで (bindgen はローカルマシンの Clang に依存しているので) Clang に -I<dir> オプションを与えれば良く 2bindgen::Builder の docs をよく眺めると clang_arg というメソッドがあるのでこれを使えばよろしい。

また build.rs の中で cargo: から始まる特別な文字列を println! することで cargo に命令することができます。(build.rs が標準出力に出力する文字列は単に cargo build しただけだと表示されないんですが -vv オプションをつければ何が吐かれてるか観察できます。)

C のライブラリを使うときに重要になるのは cargo:rustc-link-lib=NAME で、これはビルド時にネイティヴNAME という名前のライブラリとリンクしてねという指示ができます。 rustc の立場でいうと -l フラグが指定されるということらしい。余談ですが rustc で指定できるフラグの解説が The rustc book に書かれています 3 。こういうのをどんどん辿って読んでいくだけで容易に N 時間溶けます4

pkg-config

つまり MagickWand.h によって宣言される API への FFI を生成するためにはそれが依存しているヘッダファイルの場所を知る必要があり、実際にプログラムをコンパイルしたり生成されたバイナリを実行したりするときにリンクすべきライブラリは何なのかも知っている必要があります。 その情報はおそらく環境によって異なりますので、それらを取得するための共通の記録フォーマットというものが欲しくなります。そこで pkg-config が登場します。

あるライブラリのビルドに必要な include search path や library search path を .pc ファイルに記録しておくと、それらを取得するための抽象 API を pkg-config が提供してくれるので、ユーザとしては pkg-config を通して環境に依存しないコードを簡単に書くことができるというわけです。

システムに pkg-config がインストールされていれば、他のパッケージを apt や brew 等でインストールしたときに適切な .pc ファイルが自動で生成される (というのが今の僕の理解です。正直に白状すると pkg-config というものを僕は今回初めて知りました。識者のツッコミ待ちです。)

さて Rust から pkg-config を扱う crate が存在し、その名も pkg-config です。小さな crate なので docs を読めば使い方がすべてわかると思います。

error[E0428]

これでお膳立てはできたので build.rs を書き cargo build を走らせてみると

error[E0428]: a value named `FP_INFINITE` has already been defined in this module

というようなエラーが大量に出ました。

生成された bindings.rs を見てみると確かにその通りだったので、しかしこれはどうすればええんや。。。となりググったところ似たような issue を発見し、結論から言うとこの方のコードを丸パクリして解決しました:

https://github.com/rust-lang/rust-bindgen/issues/687#issuecomment-450750547

先人の功績は偉大。。。

Rust の世界から触るためのラッパーを書く

めでたく MagickWand の FFI である binding.rs が生成されました。

ところで MagickWand.h の公開 API は Rust の世界では全て

extern "C" {
    pub fn NewMagickWand() -> *mut MagickWand;
}

のように extern "C" {} で囲まれたブロックの中で宣言されています。

extern ブロックの中で宣言された関数は Rust の世界では常に unsafe なものとして扱われます 5。これらの関数を呼ぶたびに unsafe を書くのも大変なのと、たいていの場合において MagickWand の関数を直接触る部分よりもその返り値をもとに何か safe な処理をしたい部分のほうが圧倒的に広いので、unsafe な箇所を局所化して外部から触りやすくした safe なラッパーを作ることにします。

binding.rs を magickwand_bindgen という crate 名で公開しているとして、まず

use magickwand_bindgen;

pub struct Wand {
    ptr: *mut magickwand_bindgen::MagickWand,
}

のように MagickWand の生ポインタをラップした struct を作り、

impl Wand {
    pub fn new() -> Self {
        let ptr = unsafe { magickwand_bindgen::NewMagickWand() };
        Wand { ptr }
    }
}

のような塩梅で適宜 unsafeAPI 呼び出しをラップしていくという寸法です。

こんなふうに Rust の世界に処理を持ってきたときの嬉しい点として、リソース管理が RAII パターンに乗っかれるという利点が挙げられるでしょう。 NewMagickWand で確保した Wand のメモリは C 言語の世界では DestroyMagickWand を明示的に呼ぶことで解放できるのですが、Rust 側で

impl Drop for Wand {
    fn drop(&mut self) {
        unsafe {
            self.ptr = magickwand_bindgen::DestroyMagickWand(self.ptr);
        }
    }
}

のように Drop trait を実装しておけば、特に意識しなくとも各 Wand インスタンスの lifetime が尽きた時点で自動でメモリを解放してくれるようになるというわけです。

このあたりのコードは id:hadashia さんの こちら の記事、RAII の章をかなり参考にしています。

他に必要になるメソッドは MagickWand のトップページ (?) とコマンドラインの使い方ページ を見比べながら適宜追加していきます。 だいたい Magick Wand MethodsMagick Wand Image MethodsPixel Wand Methods で事足りるんじゃないかと思います。

ImageMagick 6.9 について

Ubuntu 18.04 では apt で入る ImageMagick のバージョンがデフォルトで 6.9.7 になっています 6 。最初は別にそれでもいいか〜と思っていたのですが MagickWand の API が 6.9 と 7.0 とでそこそこ破壊的に変更されていることに途中で気づきました。

(例えば MagickCompositeImage というメソッドでは clip_to_self という引数が追加されていたり 7,8MagickResizeImage 等のメソッドに渡す FilterType に変更が見られたりします 9,10 。しれっと WelshFilterWelchFilter にスペル変更されてたりして面白いですね。 ImageMagick 6 が legacy 11 と呼ばれているのも納得という感じです。)

なので 6 と 7 の両方をサポートするのは趣味でやるにはつらいだろう、7 だけを対象にしようと決めました。

Ubuntu 18.04 では ImageMagick7 をソースからビルドする必要がありますが特に凝ったことをしなければ通常の make の流れでインストールできるはずです。

pkg-config には atleast_version という便利なメソッドがあるので、build.rs で

let wand_config = pkg_config::Config::new()
    .atleast_version("7.0")
    .probe("MagickWand")
    .expect("pkg-config MagickWand should be probed");

のように指定します。

ちなみに ImageMagick 6.9 までは MagickWand.h が wand ディレクトリの下にあったんですが 7.0 では MagickWand ディレクトリの下に移動されています。 これもバージョン間の非互換性を感じたささやかな点です。

やっていき

Slack Workspace に登録した emoji とその URL の一覧は emoji.list API から取得できるので、あとはそれを reqwest なりなんなりで GET してガッと MagickWand で影をつければ完了です (雑)。

注意すべき点として、 ImageMagickコマンドラインオプションは実は setting 系のオプションと operator 系のオプションに分類されます (このあたり を読んでください)。 もともとのコマンドラインで行っていた操作がどの wand にどういう副作用を起こしていたのかを MagickWand API に翻訳する作業に少し頭を使うかもしれません。

補足: GitHub Actions における Cargo 依存パッケージのキャッシュ

GitHub Actions では各種依存パッケージ (に限らず任意のファイル) のキャッシュが可能 12 で、頻繁には変化しないようなファイルをキャッシュしておくことで workflow を高速化することができます。

さて Cargo の場合はというと、既に本家 actions/cache リポジトリに設定の example が公開されていますので、これを丸パクリすれば設定完了です。 ありがたい話ですね。

今後の課題

gif アニメ対応

こんなアニメーションが生成されてしまう:

f:id:pione30:20200406230731g:plain

何もわからない、なぜ。。。

マルチスレッド化

The Book の Fearless Concurrency の章を読み直して完全理解

グローバルな環境初期化と終了処理

MagickWand のインスタンスを作る前に MagickWandGenesis というメソッドを一回呼んで

initializes the MagickWand environment

し、プログラム終了時には MagickWandTerminus というメソッドを呼んで

terminates the MagickWand environment

しないといけない。

MagickWandGenesis のほうは std::sync::Once という便利なものがあるので、Wand::new するときに

use std::sync::Once;

static GENESIS: Once = Once::new();

fn magick_wand_genesis() {
    GENESIS.call_once(|| unsafe {
        magickwand_bindgen::MagickWandGenesis();
    });
}

impl Wand {
    pub fn new() -> Self {
        magick_wand_genesis();

        let ptr = unsafe { magickwand_bindgen::NewMagickWand() };
        Wand { ptr }
    }
}

しておけば、Wand インスタンスを作るときに MagickWandGenesis を一度だけ呼ぶことができる。

しかし MagickWandTerminus のほうはプログラムの終了時まで呼んではいけないので、単純に Wand::drop の中に書くわけにはいかない。「環境」を表すラッパー struct を作ると良いのかなと思うけど、そいつの lifetime が全ての Wand インスタンスより長いことを保証するにはどうしたもんかな。。と悩んでいる。

あと今試しにプログラム終了時に手動で MagickWandTerminus を呼んでみたら

slack-emoji-darkmode-shader: MagickCore/semaphore.c:295: LockSemaphoreInfo: Assertion `semaphore_info != (SemaphoreInfo *) NULL' failed.
zsh: abort (core dumped)

となってしまってなんでやねん。。。になっとる