2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ruby の Cairo をちょっぴり書きやすくする

Posted at

Ruby で画像や PDF などのちょっとしたグラフィックスを作りたいとき,候補に上がるツールとして Cairo がある。

Cairo は多様なプログラミング言語で利用されている。良い感じに抽象化されているため,PNG も PDF も同じように扱うことができる。

Ruby では,cairo という gem を使う。

使い方は,かなり古いが Rubyist Magazine の『cairo: 2 次元画像描画ライブラリ』という記事(2007 年)が役にたつ。
内容に古い点があるが,大部分は今も通用すると思う。
(記事のとおりにやってうまくいかない点が出てきたら本記事のコメント欄で質問してくださってもいいです)

公式のリファレンスはここ:

最もよく参照するページは,Cairo::Context のメソッドを並べたここだろう:

この記事の目的

Ruby で Cairo を使っていて,かったるいと思うことがしばしばある。

しかし Cairo::Context にたった一つのメソッドを追加するだけで,コードがちょっぴり書きやすくなる。

この記事ではそのメソッドを紹介したい。

かったるさ

Cairo::Context が提供するメソッド群は,ある意味原始的なものばかりだ。

これらのみを使ってコードを書くときのかったるさをこの節で説明しよう。

こんな画像が作りたいとする:

cairo-sample.png

これは以下のコードで描ける(あらかじめ gem install cairo しておこう)。

require "cairo"

image_width = 300
image_height = 200

surface = Cairo::ImageSurface.new Cairo::FORMAT_ARGB32, image_width, image_height
context = Cairo::Context.new surface

# 背景を白で塗りつぶす
context.rectangle 0, 0, image_width, image_height
context.set_source_color "white"
context.fill

# 文字
context.set_source_color "black"
context.set_font_size 25
context.move_to 50, 70
context.show_text "Hello,"
context.move_to 50, 100
context.set_source_color "orange"
context.show_text "Cairo!"

# 四隅の L 字状の印
margin = 30
line_length = 20

context.set_source_color "black"

context.move_to margin, margin + line_length
context.rel_line_to 0, -line_length
context.rel_line_to line_length, 0

context.move_to image_width - margin, margin + line_length
context.rel_line_to 0, -line_length
context.rel_line_to -line_length, 0

context.move_to margin, image_height - margin - line_length
context.rel_line_to 0, line_length
context.rel_line_to line_length, 0

context.move_to image_width - margin, image_height - margin - line_length
context.rel_line_to 0, line_length
context.rel_line_to -line_length, 0

context.stroke

# PNG 出力
surface.write_to_png "cairo-sample.png"

変更を局所化したい

サンプルコードでは,set_source_color を使って何度も色の指定をしている。

デフォルトは黒なのだが,背景を白にしたあとで,「Hello,」を書くために黒に戻し,「Cairo!」をオレンジにしたあとで,四隅の印を描くためにまた黒に戻している。

かったるい。

「ここだけオレンジにしたい」というときは,save を使って

context.save do
  context.set_source_color "orange"
  context.show_text "Cairo!"
end

のように書けばよい。
こう書くと,ブロック内でのみ色がオレンジになる。

位置の移動をまとめて

サンプルコードで,「Hello,」「Cairo!」の 2 行全体を少し右下に動かしたとしたら?
2 箇所にある move_to の座標を両方変更するのは面倒だし,計算間違いもしやすい。

こういうとき,座標系を移動する translate が使える。

ただ,この座標系の移動も局所的に行いたいので,さきほどの save を併用することにする。

こんなふうに書ける:

context.save do
  context.translate 50, 70

  context.move_to 0, 0
  context.show_text "Hello,"
  context.move_to 0, 30
  context.set_source_color "orange"
  context.show_text "Cairo!"
end

これで,translate の引数さえ動かせばよくなった。

変更に強いだけでなく,コードを最初に書き下ろす際にも,「この基準点から見てココ」と考えやすくなる。

反転させたい

四隅の L 字状の印は,向きが違うだけで全て同じ形だし,配置も上下・左右が対称になっている。

L 字を描くコードを四つ並べるのではなく,二重のループを使ってスマートに書けないだろうか。

さきほどの translate に似たメソッドとして,scale というものがある。

これは座標系を拡大・縮小するものなのだが,水平方向と垂直方向で別々の拡大率が指定できるほか,負の拡大率を指定すれば水平方向や垂直方向に(鏡で映したように)反転させることができる。

たとえば水平方向の拡大率を -1 とすれば左右を反転させることができるのだ。

L 字部分はこんなふうに書けるだろう。まず基準点を画像の中心に据え,そのうえで水平・垂直方向の拡大率を 1-1 にしているのがポイントだ。

context.save do
  half_width = image_width * 0.5
  half_height = image_height * 0.5
  context.translate half_width, half_height
  
  [1, -1].each do |scale_x|
    [1, -1].each do |scale_y|
      context.save do
        context.scale scale_x, scale_y

        context.move_to half_width - margin, half_height - line_length - margin
        context.rel_line_to 0, line_length
        context.rel_line_to -line_length, 0
      end
    end
  end
  context.stroke
end

save が入れ子になっていることに注意されたい。
内側の save を省略すると,2 回目以降の scale は「前回スケールしたものに対してスケールする」ということになって,期待した結果が得られない。

context. context. ってうぜぇ

Cairo を使うコードでは,Cairo::Context オブジェクトに対するメソッド呼び出しを大量に書くことになる。

サンプルコードでも,context. が大量に出てきて目障りだと思われたのではないだろうか。

これは Ruby の組み込みメソッドである BasicObject#instance_eval を使って解決することが一応可能だ。

つまり,

context.instance_eval do
  move_to 80, 120
  show_text "hoge"
end

のように書くのである。
このコードでは,context がブロック内の self になるので,move_toshow_text の呼び出しでレシーバーが省略できるのだ。

ただ,このやり方には欠点もある。

ブロックの中では self が違ってしまうので,以下のようなことが起こる:

  • ブロック外ではレシーバーを省略して呼び出せていたメソッドがそのままでは呼び出せない
  • ブロック外では参照できていたインスタンス変数が参照できない

そこで,使いたいものをブロック外でいったんローカル変数にしまう,といった対処が必要になる(ローカル変数に関しては,ブロック外で定義されていたものがブロック内でも参照できる)。

これは面倒だし,場合によってはかえってコードを複雑化してしまう。
この方法を使うかどうかは場合によりけりだろう。

全部入りのメソッド

かったるさが理解できたところで,それを軽減するメソッドを紹介しよう。

これは,Cairo::Context オブジェクトに生やすもので,where という名前にしよう。

並行移動やスケールを引数で指定する。
描画コードはブロック内に記述する。
ブロック内で行なった変更は後に影響を与えない。

前節までには述べなかったが,並行移動やスケール変更ができるんなら当然,回転もできるようにしたい。

定義はこうだ:

module CairoContextExt
  # 座標変換したところで描かせる
  # t: 平行移動
  # r: 回転
  # s: スケール(Numeric か要素数 2 のオブジェクト)
  def where(t: [0, 0], r: 0, s: [1, 1], &block)
    s = [s, s] if s.is_a?(Numeric)
    save do
      translate t[0], t[1]
      rotate r unless r.zero?
      scale s[0], s[1]
      instance_eval(&block)
    end
  end
end

使うときは

context.extend CairoContextExt

のようにする。

並行移動,回転,スケールのキーワード引数の名前 t, r, s は,translate, rotate, scale に由来する。

引数名が 1 文字であることに異論はあると思う。
ただ,長年使ってきて,1 文字にして正解だったと個人的には感じている。

引数 t, s の値について

記事のサンプルでは,引数 t, s の値として,長さ 2 の配列を与えている。

しかし,配列である必要はない。
[] というメソッドを持っていて,0 に対し X 値,1 に対し Y 値を返すようなオブジェクトならなんでもいい。

たとえば,描画のために複雑な幾何学計算を要する場合,matrix ライブラリーを使いたいかもしれない。
このライブラリーの Vector クラスのインスタンスなら,ts にそのまま与えることができる1

それから,s の値は単一の数値でもよい。
つまり,スケールを 2 倍にしたければ

context.where s: [2, 2] do

end

などと書かなくても

context.where s: 2 do

end

でよい。

並行移動・回転・スケールの順序

where の定義のなかで,

translate t[0], t[1]
rotate r unless r.zero?
scale s[0], s[1]

の順に実行していることがとても重要だ。
(ユーザーはあまり意識しなくてもいいことだが)

回転とスケールは互いに独立なので,順序を入れ替えても結果は変わらない。

しかし,これらと並行移動の順序を入れ替えると結果が違ってしまう。

たとえば,ある点 [x, y] に,30° 左回転した文字を置きたければ,

context.translate x, y
context.rotate 180 / Math::PI * -30
context.show "hoge"

のように書かなければならない。
もし順序を入れ替えて

context.rotate 180 / Math::PI * -30
context.translate x, y
context.show "hoge"

のように書いたら,translate の引数は rotate で回転した座標系における座標値と解釈されてしまう。

リファクタリング後

最初に掲げたコードが where を使ってどのように書き換えられるかを以下に示す。

require "cairo"

module CairoContextExt
  # 座標変換したところで描かせる
  # t: 平行移動
  # r: 回転
  # s: スケール(Numeric か要素数 2 のオブジェクト)
  def where(t: [0, 0], r: 0, s: [1, 1], &block)
    s = [s, s] if s.is_a?(Numeric)
    save do
      translate t[0], t[1]
      rotate r unless r.zero?
      scale s[0], s[1]
      instance_eval(&block)
    end
  end
end

image_width = 300
image_height = 200

surface = Cairo::ImageSurface.new Cairo::FORMAT_ARGB32, image_width, image_height
context = Cairo::Context.new surface
context.extend CairoContextExt

# 背景を白で塗りつぶす
context.where do
  rectangle 0, 0, image_width, image_height
  set_source_color "white"
  fill
end

# 文字
context.where t: [50, 70] do
  set_font_size 25

  move_to 0, 0
  show_text "Hello,"
  move_to 0, 30
  set_source_color "orange"
  show_text "Cairo!"
end

# 四隅の L 字状の印
half_width = image_width * 0.5
half_height = image_height * 0.5

context.where t: [half_width, half_height] do
  margin = 30
  line_length = 20

  [1, -1].each do |scale_x|
    [1, -1].each do |scale_y|
      where s: [scale_x, scale_y] do
        move_to half_width - margin, half_height - line_length - margin
        rel_line_to 0, line_length
        rel_line_to -line_length, 0
      end
    end
  end
  stroke
end

# PNG 出力
surface.write_to_png "cairo-sample.png"

おわりに

わずか数行のメソッドを定義しただけだが,Cairo を使うのが少し楽になったと感じてもらえたと思う2

さきに述べたように,incetance_eval を使うのは一長一短なので,使うかどうかは引数で切り替えられるようにしたほうがよいかもしれない。

私自身は,where だけでなく,さまざまな便利メソッドを Cairo::Context に追加するライブラリー(gem)を作って使っている。

残念ながら広く使っていただけるようにはなっていないので,公開はしていない。

  1. 私自身は自前の幾何学計算ライブラリーを作って,それを使ったりしている。

  2. 自画自賛。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?