WebBrowserコンポーネントいろいろ

WebBrowserコンポーネントを .NET Framework であれこれ料理したときのいろいろ。


    レンダリングバージョンを変更する

    WebBrowserコンポーネント1は、何もせずにそのまま使用すると必ず IE7相当のレンダリングエンジンと JavaScriptエンジンになってしまう。これは <meta http-equiv="x-ua-compatible" content="IE=Edge" /> などを食わせても変わらない。2 この問題を解決するには、レジストリを触るしか方法がない。

    Try  
        Dim RendererVersion As Integer = 11000
        Dim ExecName As String = Path.GetFileName(Environment.GetCommandLineArgs()(0))
        Dim Regkey As RegistryKey = _
            Registry.CurrentUser.CreateSubKey( _
                "SOFTWARE\Microsoft\Internet Explorer\Main\FeatureControl\FEATURE_BROWSER_EMULATION")
        Regkey.SetValue(ExecName, RendererVersion)
        Regkey.Close()
    Catch ex As Exception  
        Console.WriteLine("CreateSubKey: " & ex.StackTrace)
    End Try
    
    WebBrowser1 = New WebBrowser()  
    WebBrowser1.ObjectForScripting = Me  
    

    レジストリには、キー=実行ファイル(EXE)名、値=IEバージョンの1000倍のDWORD値 を登録する。存在しない時の既定値は 7000 と見做される。またこのとき Visual Studio のデバッグRunでは .EXE名が 〜.vhost.exe になるのを考慮する必要がある。結局のところ、プログラム実行毎に毎回自分自身の .EXE名を新規/上書き登録してしまうのが手堅い。またレジストリの登録タイミングは初めて New WebBrowser() する以前でなければならない。


    マウスイベントを VB.NET側で使いたい

    WebBrowserコンポーネント内で発生したイベントはほぼすべて JavaScript内で完結して外部(Windows.Forms)に伝播しない。必要なら JavaScriptから ObjectForScripting機能経由で Formクラスを呼んで RaiseEvent しろということになっている。だがそういう毎回使うような定形処理はサブクラス化してまとめといたほうが良いに決まっている。

    Imports System.Windows.Forms  
    Public Class WebBrowserEx  
        Inherits WebBrowser
    
        Private WithEvents Body As HtmlElement
    
        Public Shadows Event MouseDown(ByVal sender As Object, ByVal e As MouseEventArgs)
        Public Shadows Event MouseLeave(ByVal sender As Object, ByVal e As MouseEventArgs)
    
        Protected Overrides Sub OnDocumentCompleted(ByVal e As WebBrowserDocumentCompletedEventArgs)
            Me.Body = Me.Document.Body
            MyBase.OnDocumentCompleted(e)
        End Sub
    
        Private Sub Body_MouseDown(ByVal sender As Object, _
                                   ByVal e As HtmlElementEventArgs) _
                               Handles Body.MouseDown
            RaiseEvent MouseDown(sender, New MouseEventArgs(e.MouseButtonsPressed, 1, _
                                                            e.ClientMousePosition.X, _
                                                            e.ClientMousePosition.Y, 0))
        End Sub
    
        Private Sub Body_MouseLeave(ByVal sender As Object, _
                                    ByVal e As HtmlElementEventArgs) _
                                Handles Body.MouseLeave
            RaiseEvent MouseLeave(sender, New MouseEventArgs(MouseButtons.None, 0, _
                                                             e.ClientMousePosition.X, _
                                                             e.ClientMousePosition.Y, 0))
    End Class
    
    ' WebBrowser1 = New WebBrowserEx()  
    ' WebBrowser1.Navigate("about:blank")  
    

    ここでは一部しか書いてないが、Up/Enter/Move/Over も同様に実装3できる。

    ただこの実装においては、WebBrowserコンポーネント内で Refresh() する、あるいはF5(Ctrl+R)キーを押してのページリロードに対応できない。というのも .NET版の WebBrowserコンポーネントはリロード時に一切イベントを発火しないからだ。ページ内のJavaScriptでは通常通り onload等が発火するので ObjectForScriptingで補完する(Me.Document.Body を読み直す)必要がある。


    JScriptTypeInfo と .NET オブジェクトの相互変換

    ObjectForScriptingと InvokeScriptを使えば .NETと JavaScriptのユーザ関数を相互に呼び出し合うことが出来る。この時確実に引き渡せる引数は、数値・文字列・真偽値・null の4種類しかない。配列や連想配列(ハッシュ)はシリアライズする必要がある。だが実際には次のコードを試せば解るが、JavaScriptで生成された複雑なデータ構造は .NETのなかを素通りさせることができる。

    var r = window.external.foo({a:[123, 456], h:{s:789}, s:"Test"});  
    function bar (data) {  
        return {b:data.a[0] + data.h.s};
    }
    console.log(r.b); // 912  
    
    Public Function foo (ByVal d As Object) As Object  
        Console.log(d.s)    ' Test
        Console.log(d.a(1)) ' 456
        Console.log(d.h.s)  ' 789
        Return WebBrowser1.Document.InvokeScript("bar", d)
    End Function  
    

    WebBrowserコンポーネントにはこのJavaScriptネイティブのデータ構造=JScriptTypeInfo型オブジェクトを解釈する機能(オブジェクト定義)がない。単純な文字列や数値は ToString()や ToInt32()メソッドで変換できるし、ハッシュの場合は Item() メソッドこそ使えるものの Count() も Containes() もないので「静的な構造体」以外は直接扱えないのだ。

    Console.log(d.s)               ' sメンバーがあれば読み出せるがなければ例外発生  
    Console.log(d.Item("s"))       ' これもメンバーがなければ例外発生  
    Console.log(d.Conatines("s"))  ' これもメンバーがなければ例外発生  
    

    .NETのほうでは3.5以降で JSON文字列をシリアライズ/デシリアライズ出来るため、引数も返値も JSON文字列化する取り決めにすれば良い話なのだが、ネイティブにデータ構造を受け渡しできたほうが(JavaScript側の視点では)利便性が良い。もっぱら融通が効かないのは静的型言語である .NETの側なので、こちらをもう少し頑張ってみよう。

    取り敢えず、JScriptTypeInfoを読んで比較的扱いやすい Dictionary型にまるごと変換するなら次のようなコードが書ける。いったん IExpando型にキャストすればメンバーのプロパティリストを得られるので、CallByName()で実体を得ることができる。これを再帰的に適用すればいちおう全体にアクセスすることが出来る。ただし配列については手を抜いてハッシュ化してしまっているが。

    ' Imports System.Reflection  
    ' Imports System.Runtime.InteropServices.Expando
    
    Private Function JscInfoToDict(ByVal jscinfo As Object) As Dictionary(Of String, Object)  
        Dim dict = New Dictionary(Of String, Object)
        Dim keys() As String = DirectCast(jscinfo, IExpando).GetProperties( _
            BindingFlags.Default).Select(Function(p) p.Name).ToArray()
        For Each Key In keys
            Dim Var As Object = CallByName(jscinfo, Key, CallType.Get)
            ' Console.WriteLine("Key:" & Key & ", Var:" & Var.ToString & ", Type:" & Var.GetType.ToString)
            If Var.GetType.ToString = "System.__ComObject" Then  ' これだけでは配列とハッシュの区別がつけられない
                dict.Add(Key, JscInfoToDict(Var))
            Else
                dict.Add(Key, Var)
            End If
        Next
        Return dict
    End Function  
    

    これとは逆に、Dictionary型を JScriptTypeInfo型に変換するのは、JScriptTypeInfo型の定義がないので不可能だ。ならば「JScriptTypeInfo型を知っている」言語に変換を委託してしまえば良い。そもそも相互変換をしたい場面において WebBrowserコンポーネントを使っていないということは滅多にないだろうから、InvokeScriptで JSONクラスメソッドを呼び出してしまえば良い。4

    ' Imports System.Web.Script.Serialization
    
    ' Friend WithEvents Converter As WebBrowser  
    ' Converter = New WebBrowser()  
    ' Converter.Navigate(New Uri("about:blank"))
    
    Private Sub Converter_DocumentCompleted(ByVal sender As Object, _  
                                            ByVal e As EventArgs) _
                                        Handles Converter.DocumentCompleted
        sender.Document.InvokeScript("eval", New Object() {"function __j2s(j){return JSON.stringify(j)};"})
        sender.Document.InvokeScript("eval", New Object() {"function __s2j(j){return JSON.parse(j)};"})
    End Sub
    
    Public Function jobject_decoder(ByVal jscinfo As Object) As Dictionary(Of String, Object)  
        Dim jss As JavaScriptSerializer = New JavaScriptSerializer()
        Dim jstring As String = Converter.Document.InvokeScript("__j2s", New Object() {jscinfo})
        Return jss.Deserialize(Of Dictionary(Of String, Object))(If(jstring, "{}"))
    End Function
    
    Public Function jobject_encoder(ByVal dict As Dictionary(Of String, Object)) As Object  
        Dim jss As JavaScriptSerializer = New JavaScriptSerializer()
        Dim jstring As String = jss.Serialize(dict)
        Return Converter.Document.InvokeScript("__s2j", New Object() {jstring})
    End Function  
    

    .NET版の InvokeScript() は COM版のそれとは異なり、ルート要素のユーザ関数しか呼び出せず、クラスメソッドを直接叩くことが出来ない。本来なら JSON.stringify JSON.parse を直接使いたいが使えないので、DocumentCompleted を待ってから変換用ユーザ関数を eval で登録する方法を取った。なお JSONクラスは IE7ではそもそも存在しないので、前述した IEバージョン変更のレジストリ登録が事前に必要になる。5


    余談:cscript.exeでの JSON変換

    Windows標準でかつ標準入出力が使える汎用インタプリタとして、cscript.exe は貴重な存在だ。うまく使えば grep/sed/awk あたりの代用にもなる。だが標準では JSON変換機能を持っていないなど基本的な言語仕様が少々古い。しかし COMオブジェクトとして HTMLファイルにアクセスすると、その中の最新 JavaScriptエンジンから欲しい機能をアドオン的に持ってくることが出来る。

    // Ex) cscript.exe //Nologo //U jconvert.js <STDIN >STDOUT
    var htmlfile = WScript.CreateObject('htmlfile'), JSON;  
    htmlfile.write('<meta http-equiv="x-ua-compatible" content="IE=9" />');  
    htmlfile.close(JSON = htmlfile.parentWindow.JSON);
    
    while (!WScript.StdIn.AtEndOfStream) {  
        var json = JSON.parse(WScript.StdIn.ReadLine()),
        // user code
        WScript.StdOut.WriteLine(JSON.stringify(json));
    }
    

    このテクニックは色々と応用が効く。同じようにして(役に立つかどうかはともかく)jQueryをインポートすることが出来るし、include/require文代わりに使うことも出来る。

    なお cscript.exe の標準入出力は、素で起動すると ANSI すなわち日本語では CP932 になる。このため JSON文字列中のマルチバイト文字はエスケープされていなければ正しく扱えない。そこで //U オプション付きで起動すると UTF-16LE でマルチバイト文字を扱えるようになるが、改行コードも2バイト文字(ワード幅)にしなければならない罠がある。更に余談となるが、cscript.exe のスクリプトコードは、ANSI(CP932)または UTF-16LE(BOM付き)で記述されていなければならない。一般的な UTF-8N では文字化けが発生する。6


    1. ここでのサンプルコードは VB.NET だが、C#NETでも JScript.NET でも基本は変わらない。.NETではどの言語でコードを記述しようと、結果的に生成されるのは同一の共通言語オブジェクトだ。なお .NET Frameworkのバージョンは 4.0以降を前提とする。いまさら Vista 以前を動作対象にすることもないし、Win7でも 4.0 Runtime を入れれば済むことだ。

    2. x-ua-compatible が実装されたのはIE8以降なのだから当然なのだが。

    3. UpはDownと、Enter/Move/OverはLeaveとおなじ MouseEventArgs(...) 宣言を書く。

    4. ここで使っている Converterはコントロール表示には使わないので Windows.Forms.Controls に Add する必要はない。

    5. IEバージョンを変えないならば、Navigate() で読み込むページのなかのほうに、JSON変換関数を JavaScriptで自作して記述することになる。もっともそこまでの手間を掛けるなら、.NET側で頑張るよりも JavaScriptの方で普通に JSON変換して受け渡したほうがコード記述量は増えるものの、単純だろう。

    6. これに気付いておらず「wscript/cscript は日本語が扱えない」とする発言や書籍がままあるので注意を要する。Windows本来のネイティブ文字コードは結構な昔から「ANSIおよびUnicode(16)」と決まっている。「メモ帳」が UTF-8Nファイルをそのまま保存せず、おせっかいにもBOMを挿入したりする所以もまたこれである。

    RECENT LINKS