(Python3 アドベントカレンダー/18日目) Python3 で blockdiag を動かしてみたかった。

Python 界の芸人、@tk0miya です。
突然ですが今日は Python3 Advent Calendar のエントリを書きます。
昨日 Python mini hack-a-thon で @terapyon に強制されるまで
Python3 なんて全く触ったことがなかったのになぜか参加する羽目になりました。

ぼやいていてもしょうがないので早速進めていきましょう。
せっかく Python3 を触るチャンスができたので、今日は Python3 で拙作 blockdiag を動かしてみようと思います。
僕が blockdiag をつくるために Python を使い始めたので、
僕にとって Python というのは blockdiag 開発用言語と言っても過言ではありません。*1

というわけで、blockdiag を Python3 で動かしてみるために試行錯誤してみることにします。

まずは Python3 の環境をつくろう。

僕は Debian パッケージになっていないソフトウェアをいれるのに拒否反応を示す程度の
かわいそうな子 Debian 使い見習いです。
しかし、現在リリースされている Debian 6.0 (squeeze) では
パッケージングされている Python は 2.5, 2.6, 3.1 の 3つだけです。
Python3 の最新版である 3.2.2 を利用するには squeeze のパッケージは利用できません。

解決策として、unstable 版を使用することにしましょう。
unstable では今日現在 3.2.2 のパッケージが提供されています。

手元の環境は squeeze で動いているので、実験用の chroot 環境を作ります。

$ sudo debootstrap unstable /chroot/python3
$ sudo chroot /chroot/python3 /bin/bash

次に Python3.2 をインストールします。

# apt-get update
# apt-get install python3.2 python3.2-dev python3-setuptools

はい、簡単ですね。
試行錯誤することなくパッケージを入れることができるのはパッケージメンテナの方々のおかげなので、
apt-get yeah! と言って彼らに感謝しましょう。

PIL を動かそう。

次に依存ライブラリのひとつである PIL をインストールします。
PIL の公式パッケージは Python3 に対応していないので、
Unofficial Windows Binaries for Python Extension Packages に置いてある PIL のソースを利用します。

本来は Debian パッケージに加工すべきですが、今回は時間がないので諦めます。*2

まずは PIL のビルドに必要なツールや依存ライブラリをインストールしましょう。

# apt-get install build-essential
# apt-get install unzip
# apt-get install libjpeg8 libjpeg8-dev
# apt-get install libfreetype6 libfreetype6-dev


PIL-1.1.7-py3 のソースは Windows 用に書き換えられているため、Linux ではそのまま利用することはできません。
以下のパッチを当ててビルドできるようにします。

--- setup.py.orig       2011-12-17 16:58:43.000000000 +0000
+++ setup.py    2011-12-17 17:04:18.000000000 +0000
@@ -33,6 +33,13 @@
 #
 # TIFF_ROOT = libinclude("/opt/tiff")

+TCL_ROOT = None
+JPEG_ROOT = None
+ZLIB_ROOT = None
+TIFF_ROOT = None
+FREETYPE_ROOT = "/usr/lib/i386-linux-gnu", "/usr/include/freetype2"
+LCMS_ROOT = None
+
 if sys.platform == "win32":
     if '64 bit' in sys.version:
         TCL_ROOT = ('C:/TCL85-x64/lib', 'C:/TCL85-x64/include')

そしてビルドを実行します。

$ python3.2 setup.py build

ビルドの結果が以下のように表示されていれば成功です。
確認すべき箇所は JPEG support と FREETYPE2 support が available になっていることです。

--------------------------------------------------------------------
PIL 1.1.7 SETUP SUMMARY
--------------------------------------------------------------------
version       1.1.7
platform      linux2 3.2.2+ (default, Dec  2 2011, 11:11:47)
              [GCC 4.6.2]
--------------------------------------------------------------------
*** TKINTER support not available
--- JPEG support available
--- ZLIB (PNG/ZIP) support available
--- FREETYPE2 support available
*** LITTLECMS support not available
--------------------------------------------------------------------

最後にインストールを行います。

# python3.2 setup.py install

PIL の動作確認

PIL がちゃんとインストールされたのか、かんたんなプログラムを書いて確認してみます。
libfreetype と正しくリンクされているか確認するために、OpenType フォントによる描画も確認します。

動作確認のため、IPA フォントをインストールしておきます。
これまでフォント系のパッケージは ttf- で始まるパッケージ名でしたが、
次のリリースからは fonts- で始まる名前になったようです。パッケージを探す際は注意しましょう。

# apt-get install fonts-ipafont

そしてサンプルスクリプトです。rectangle と ellipse, text と基本要素を並べたプログラムです。

#!/usr/bin/python3.2

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont

image = Image.new('RGB', (800, 600), (256, 256, 256))
alpha = Image.new('L', image.size, 1)
image.putalpha(alpha)
drawer = ImageDraw.ImageDraw(image)

drawer.rectangle((100, 100, 200, 200), outline='black', fill='red')
drawer.ellipse((300, 100, 500, 200), outline='black', fill='red')
drawer.rectangle((150, 150, 400, 400), outline='green')

fontpath = '/usr/share/fonts/opentype/ipafont-gothic/ipagp.ttf'
ttfont = ImageFont.truetype(fontpath, 24)
drawer.text((100, 50), "Hello world", fill='black', font=ttfont)
drawer.text((100, 150), "Good-bye world", fill='black', font=ttfont)

image.save('output.png', 'PNG')

これを実行するとこんな画像が出力されます。

他の依存ライブラリを入れよう

PIL がインストールできたので他の依存ライブラリを入れていきましょう。
blockdiag は webcolors, funcparserlib というふたつのライブラリに依存しています。
それぞれを easy_install3 コマンドでインストールしていきます。

# easy_install3 webcolors
# easy_install3 funcparserlib
Searching for funcparserlib
Reading http://pypi.python.org/simple/funcparserlib/
Reading http://code.google.com/p/funcparserlib/
Best match: funcparserlib 0.3.5
Downloading http://pypi.python.org/packages/source/f/funcparserlib/funcparserlib-0.3.5.tar.gz#md5=52dfec49f2d2c4d816fe8d8c90f7dcf1
Processing funcparserlib-0.3.5.tar.gz
Running funcparserlib-0.3.5/setup.py -q bdist_egg --dist-dir /tmp/easy_install-2vhcgm/funcparserlib-0.3.5/egg-dist-tmp-18znip
warning: no files found matching '*' under directory 'tests'
  File "build/bdist.linux-i686/egg/funcparserlib/parser.py", line 118
    except NoParseError, e:
                       ^
SyntaxError: invalid syntax

  File "build/bdist.linux-i686/egg/funcparserlib/lexer.py", line 80
    def match_specs(specs, str, i, (line, pos)):
                                   ^
SyntaxError: invalid syntax

zip_safe flag not set; analyzing archive contents...
  File "/usr/local/lib/python3.2/dist-packages/funcparserlib-0.3.5-py3.2.egg/funcparserlib/parser.py", line 118
    except NoParseError, e:
                       ^
SyntaxError: invalid syntax

  File "/usr/local/lib/python3.2/dist-packages/funcparserlib-0.3.5-py3.2.egg/funcparserlib/lexer.py", line 80
    def match_specs(specs, str, i, (line, pos)):
                                   ^
SyntaxError: invalid syntax

Adding funcparserlib 0.3.5 to easy-install.pth file

Installed /usr/local/lib/python3.2/dist-packages/funcparserlib-0.3.5-py3.2.egg
Processing dependencies for funcparserlib
Finished processing dependencies for funcparserlib

easy_install でインストールしてみると、何箇所かで SyntaxError が出ていますね。
はい、ご想像のとおり funcparserlib は Python3 に非対応なのです。

というわけで、blockdiag を動かす前に依存ライブラリがうまく動かないという問題にぶつかってしまいました。
ちなみに blockdiag 自身も Python3 用には書かれていないのでうまく動かないというオチが待っています。

まとめ

blockdiag とその依存ライブラリの Python3 対応状況を確認してみました。

  • PIL: 非公式パッケージを使えば Python3 に対応している
  • webcolors: 問題ないようだ
  • funcparserlib: 非対応
  • blockdiag: 非対応

ちょっと残念な結果になりましたが、一番の強敵かと思われた PIL が動くようになっているので
もうしばらく待つと blockdiag を Python3 対応に書き換える日がやってくるかもしれませんね。
ちなみに blockdiag は python2.4 をサポートする方針にしているので、
もし Python3 対応をする場合はかなり辛い道のりがが待っているようです…。

それでは明日のアドベントカレンダーは PyConJP の座長をつとめておられる @terapyon さんにお願いしたいと思います。
なにか楽しい記事が見られるのを期待しております :-)



おまけ

というところで終わるのは非常に悔しいので、ちょちょいとコードを書いて、
blockdiag archetype みたいなものを作ってみました。

#!/usr/bin/python3.2

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont
from PIL import ImageFilter


class Constants(object):
    node_width = 120
    node_height = 80
    span_width = 50
    span_height = 40


class Node(object):
    def __init__(self, name, x, y):
        self.name = name
        self.x = x
        self.y = y


def create_canvas(size):
    image = Image.new('RGB', size, (256, 256, 256))
    alpha = Image.new('L', size, 1)
    image.putalpha(alpha)

    return image


def create_drawer(canvas):
    return ImageDraw.ImageDraw(canvas)


def create_font():
    #fontpath = '/usr/share/fonts/truetype/ipafont/ipagp.ttf'
    fontpath = '/usr/share/fonts/opentype/ipafont-gothic/ipagp.ttf'

    return ImageFont.truetype(fontpath, 24)


def smooth_canvas(canvas):
    for i in range(15):
        canvas = canvas.filter(ImageFilter.SMOOTH_MORE)

    return canvas


def draw_node(drawer, node, font):
    x = node.x * Constants.node_width + (node.x + 1) * Constants.span_width
    y = node.y * Constants.node_height + (node.y + 1) * Constants.span_height

    # draw outline
    box = (x, y, x + Constants.node_width, y + Constants.node_height)
    drawer.rectangle(box, outline='black', fill='white')

    # draw label
    size = font.getsize(node.name)
    textxy = (x + (Constants.node_width - size[0]) / 2,
              y + (Constants.node_height - size[1]) / 2)
    drawer.text(textxy, node.name, fill='black', font=font)


def draw_node_shadow(drawer, node):
    shift = 5
    x = node.x * Constants.node_width + (node.x + 1) * Constants.span_width
    y = node.y * Constants.node_height + (node.y + 1) * Constants.span_height

    # draw shadow
    box = (x + shift, y + shift,
           x + Constants.node_width + shift, y + Constants.node_height + shift)
    drawer.rectangle(box, outline='black', fill='black')


def draw_edge(drawer, edge):
    node1 = [n for n in nodes  if n.name == edge[0]].pop()
    x1 = (node1.x + 1) * (Constants.node_width + Constants.span_width)
    y1 = int((node1.y + 0.5) * Constants.node_height) + (node1.y + 1) * Constants.span_height

    node2 = [n for n in nodes  if n.name == edge[1]].pop()
    x2 = node2.x * Constants.node_width + (node2.x + 1) * Constants.span_width
    y2 = int((node2.y + 0.5) * Constants.node_height) + (node2.y + 1) * Constants.span_height

    # draw edge (straight line :p)
    drawer.line((x1, y1, x2, y2), fill='black')


def main():
    size = (800, 300)
    canvas = create_canvas(size)
    drawer = create_drawer(canvas)
    font = create_font()

    # draw shadow
    for node in nodes:
        draw_node_shadow(drawer, node)

    canvas = smooth_canvas(canvas)
    drawer = create_drawer(canvas)

    for node in nodes:
        draw_node(drawer, node, font)

    for edge in edges:
        draw_edge(drawer, edge)

    canvas.save('output.png', 'PNG')


#######################
# diagram definitions #
#######################
nodes = [Node('A', 0, 0),
         Node('B', 1, 0),
         Node('C', 2, 0),
         Node('D', 2, 1)]

edges = [('A', 'B'), ('B', 'C'), ('B', 'D')]


main()

このスクリプトは "diagram definitions" で定義しているノードやエッジの情報に基づいて図を生成します。
レイアウト部分は複雑になるので、ノード名と座標(x, y)を指定するようにしています。
この例は blockdiag の

{
  A -> B -> C, D;
}

と同じ構成のノードを書いています。

上記のコードを実行すると以下のような図が得られます。

まるで blockdiag のような出力ですね!Python3 で blockdiag が動いているようにみえます!
…などと Python3 で動かすことができなかった切なさを紛らわせるのでした。
おしまい。

*1:実際仕事は PHP ばかりですし、Perl 使いに憧れて生きています

*2:ソースから入れてもいいように実験環境作ったわけですしね