PyQt4 の QtWebKit モジュールを使う

Python Hack-a-thon 2010.11 : ATND で @ による PyQt4 のハンズオンに参加してきました。お題は以下の通りです。

「メディアプレーヤ&スクリーンショット作成機能付き俺様ブラウザを作りながらPyQt4の基礎を学ぶ」
GUIとPhononというマルチメディア系の基礎を扱います。

これまで Qt のリファレンスドキュメントやサンプルコードを見よう見まねで「おまじない」として利用していたところがあり、ハンズオンの解説を聞いていて、ハンズオンに参加する前よりもずっと Qt の仕組みの理解度が高まりました。解説されていた内容を「分かったつもり」で終わらせないように自分なりに整理しながらまとめてみます。

PyQt4 の開発環境を整える

Fedora での Qt/PyQt のインストール
$ sudo yum -y install qt qt-devel qt-doc PyQt4 PyQt4-devel

また wikipedia:インタラクティブシェル を IPython で使用するなら "-q4thread" オプションを指定すると、Qt のイベントループを別スレッドで実行してくれます。このオプションは IPython で Qt の GUI なオブジェクトを扱いたいときに必要になります。内部的には別スレッドで QApplicationインスタンス化してくれているようです。
IPython のスレッディングオプション に詳細があります。

$ ipython -q4thread
In [1]: from PyQt4.QtGui import *; from PyQt4.QtCore import *
In [2]: b = QPushButton("Test")
In [3]: b.show()
In [4]: # QApplication を呼び出さずにボタンを作成して表示できる

Qt Assistant を利用する

ハンズオンの大きな収穫の1つ、Qt Assistant が良い、素晴らしい!!!
これまで PyQt4 リファレンスドキュメント でモジュールやクラスの詳細を調べていました。継承階層を辿るときやメソッドがどこで定義されているかを調べるのがかなり面倒でした。Qt Assistant のリファレンスドキュメントは Qt そのもの(C++) のドキュメントではありますが、PyQt でもほとんどが1対1で対応するのであまり困りません。@ransui は逆に引数の型が分かって良いと仰ってました(^ ^;;

Qt Assistant を起動する
$ assistant-qt4

例えば、QPushButton の setToolTip() メソッドを調べたいとします。

setToolTip() メソッドを持つクラスが一覧で分かるので調べるのが簡単です。慣れれば継承階層も予想はつきますが、分からない場合は inspect.getclasstree() から2つ上位の親が分かるので、深い継承階層はその親を辿っていけば調べられます。この例では QPushButton は QWidget から派生していることが分かります。

In [30]: inspect.getclasstree([QPushButton])
Out[30]: 
[(<class 'PyQt4.QtGui.QAbstractButton'>, (<class 'PyQt4.QtGui.QWidget'>,)),
 [(<class 'PyQt4.QtGui.QPushButton'>, (<class 'PyQt4.QtGui.QAbstractButton'>,))]]

Qt Designer を利用する

画面の見た目や構成を作成するのが Qt Designer です。分かり易く(?)言うと、MS Visual Basic のようにフォームへ部品をペタペタ貼り付けていきます。作成した画面は xml で出力されて pyuic4 コマンドを利用して Python スクリプトコンパイルします。

Qt Designer を起動して .ui ファイルを作成する
$ designer-qt4

.ui ファイルをコンパイルする
$ pyuic4 -o MainWindow.py MainWindowUI.ui

変換された MainWindow.py を覗いてみます。1つめのポイントとして、Ui_MainWindow クラスは Qt の MainWindow ウィジェットを Mixin で定義していることです。Mixin のメリットとして Qt ウィジェットの変更が容易になります。Mixin を使った方が疎結合な状態で望ましく、Qt そのものもそういった考えで開発されていると説明されていました。

class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
...

2つめのポイントとして、PyQt4 のスロットを関数名で対応付ける - forest book があります。

...
        QtCore.QMetaObject.connectSlotsByName(MainWindow)
...

これは説明されていなかった気がしますが、3つめのポイントとして、国際化(i18n)対応のコードも自動生成されています。

...
        MainWindow.setWindowTitle(QtGui.QApplication.translate(
            "MainWindow", "PyQt4 : Simple Browser", None, QtGui.QApplication.UnicodeUTF8))
...

メインプログラムからは以下のように Ui_MainWindow が利用されます。

from MainWindowUI import Ui_MainWindow

class MainWindow(QtGui.QMainWindow, Ui_MainWindow):
    def __init__(self, *args, **kw):
        QtGui.QMainWindow.__init__(self, *args, **kw)
        self.setupUi(self)
...

Qt のシグナルとスロットの仕組みをもう少し

Qt の世界で言うシグナル/スロットの仕組みはデリゲートのこと

シグナルとスロットは Qt プログラミングの特徴の1つと何かで見た気がします。別の概念で説明されたので印象に残った言葉でした。C# ではそのまま wikipedia:デリゲート_(プログラミング) という名前の機能があるようです*1。「デリゲート」という言葉はよく聞くけど、実はよく分かってなくて、、、と @ に尋ねてみました。処理を代替するという側面からプロキシとよく似ていて、クライアントの視点から見ると直接、代替する相手とやり取りとりするならプロキシと言っても良い。依頼されたオブジェクトの視点から見ると、依頼された処理をさらに誰かへ依頼するのがデリゲートだよと簡潔に教えてもらいました。wikipedia:委譲 を見たらプロキシは実体を知っているとあったので補足してイメージしてみました。

つまり Qt の世界でいうシグナル/スロットは、あるイベントの対するイベントハンドラの実装を分離して、疎結合な状態にする工夫の1つでもあるのですね。

pyqtSlot() デコレータの name パラメータを使う

PyQt4 ではシグナルに connect() すればスロットが登録されます。QtCore.pyqtSlot() デコレータを使わなくても処理は実装できますが、デコレータで型を指定すると若干パフォーマンス的に有効でした。何よりもそのメソッドがスロットだと明示的に分かり易くて良いです。

...
    @QtCore.pyqtSlot(name="on_take_screenshot_button_clicked")
    def takeScreenShot(self):
        self.screenshot.render(self.webView.url())
...

ここで Qt のスロット名をキーワード引数 name で指定することができます。この機能を知ってはいたけれど、何が嬉しいのか私は分かっていませんでした。これもハンズオンに参加して目から鱗な感じでした。この例では、take_screenshot_button というオブジェクト名のボタンがクリックされたときに takeScreenShot() メソッドが呼び出されます。ここでスロット名と実際のメソッド名を分離しておくと、この処理を利用するウィジェットが変更されたとき(オブジェクト名を変えたとか、ボタンをやめてコンボボックスにしたとか)に影響を小さくできます。ツールの再利用性が高まり、また UI とスロットとの結合度も低くすることができるのですね。

QtWebKit モジュールを使う

さて本題の QtWebKit モジュールです。

Qt Assistant で QWebView を調べると QWebPage と QWebFrame で構成されているとあります。また補足として、QWidget の属性が必要ない、つまり GUI なツールが必要ないなら QWebPage と QWebFrame は独立して使うことができます。

ハンズオンで紹介されていたサンプルアプリケーションの1つで、ブラウザでアクセスしたページのスクリーンショットを取得するサンプルがありました*2。それらを参考にしながら、CUIスクリーンショットを取得するアプリケーションを作成してみます。Qt を使ってわざわざ CUI なツールを作るというのも何だか変な感じがしますね(^ ^;;

ハンズオンのサンプルコードを参考にしつつ、UI は必要ないので QWidget ではなく QObject を継承したアプリケーションを作成します。本来は QCoreApplication でイベントループを実行したいところですが、QtWebKit が QtGui に依存するので QApplication で行う必要があるようです。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Python Hack-a-thon 2010.11 / PyQt4 Hands on
About QtWebKit 04 : ScreenShot 機能を CUI で実行してみる
"""

import sys
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4.QtWebKit import *

class ScreenShot(QObject):
    renderFinished = pyqtSignal(QImage, QString)
    renderFailed = pyqtSignal()

    def __init__(self, parent=None):
        super(ScreenShot, self).__init__(parent)
        self.web_page = QWebPage(self, objectName="web_page")
        self.main_frame = self.web_page.mainFrame()
        self.progress = ""
        QMetaObject.connectSlotsByName(self)

    def render(self, target_url):
        self.main_frame.load(QUrl(target_url))

    @pyqtSlot(name="on_web_page_loadStarted")
    def showStart(self):
        print "Loading Start..."

    @pyqtSlot(int, name="on_web_page_loadProgress")
    def showProgress(self, progress):
        factor = progress / 10
        self.progress = "%s: %d" % ("#" * factor, progress)
        print self.progress

    @pyqtSlot(bool, name="on_web_page_loadFinished")
    def takeScreenShot(self, status):
        if status:
            print "Loading End"
            contents_size = self.main_frame.contentsSize()
            viewport_size = QSize(contents_size.width() + 32,
                                  contents_size.height() + 32)
            self.web_page.setViewportSize(viewport_size)
            offscreen_image = QImage(viewport_size, QImage.Format_RGB32)

            painter = QPainter(offscreen_image)
            self.main_frame.render(painter)
            painter.end()

            title = self.main_frame.title()
            self.renderFinished.emit(offscreen_image, title)
        else:
            print "Loading Error"
            self.renderFailed.emit()

class MainProcess(QObject):
    finished = pyqtSignal()

    def __init__(self, parent=None):
        super(MainProcess, self).__init__(parent)
        self.screenshot = ScreenShot(self)
        self.screenshot.setObjectName("screenshot")
        self.screenshot.renderFailed.connect(self.finished.emit)
        QMetaObject.connectSlotsByName(self)

    def process(self):
        url = None
        while not url:
            url = raw_input("Input URL: ")
        self.screenshot.render(url)

    @pyqtSlot(QImage, QString, name="on_screenshot_renderFinished")
    def save_image(self, image, title):
        image.save("%s.png" % title)
        self.finished.emit()

def config_websettings():
    s = QWebSettings.globalSettings()
    s.setAttribute(QWebSettings.PluginsEnabled, False)
    s.setAttribute(QWebSettings.JavaEnabled, True)
    s.setAttribute(QWebSettings.JavascriptEnabled, True)
    s.setAttribute(QWebSettings.LocalContentCanAccessRemoteUrls, True)

def main():
    app = QApplication(sys.argv)
    config_websettings()
    m = MainProcess(app)
    m.finished.connect(app.quit)
    m.process()
    app.exec_()

if __name__ == "__main__":
    main()

実行結果。

$ python ApplicationMain.py 
Input URL: http://d.hatena.ne.jp/
Loading Start...
#: 10
##: 20
##: 26
###: 31
####: 48
#####: 50
######: 68
#######: 70
##########: 100
Loading End

$ ls *.png
はてなダイアリー - 無料で簡単。広告のないシンプルなブログをはじめよう!.png

オリジナルのサンプルコードの補足を引用して恐縮なのですが、

...
            contents_size = self.main_frame.contentsSize()
            viewport_size = QSize(contents_size.width() + 32,
                                  contents_size.height() + 32)
            self.web_page.setViewportSize(viewport_size)
...

ページ全体のスクリーンショットを取るために contentsSize() を取得して、スクロールバーを描画しないためにマジックナンバー 32 を追加されているようです。この辺はご愛嬌ですね(^ ^;;

また、スロットの定義は、

...
    @pyqtSlot(bool, name="on_web_page_loadFinished")
    def takeScreenShot(self, status):
...

のように、あえて connect() で定義せずにデコレータで指定してみました。全体を見渡してみて、シグナルに対するスロット実装がセットでコーディングできるので分かり易いですね。

とても充実したハンズオンでした。ありがとうございました。