部分継続を用いたエンコーディング変換

最近 XPathScheme 版 (SXPath) を自前で開発しているんですが、その関連で HTML パーサーも作ってみようと思い立ちました。

が、パーサー以前の問題でいきなり躓いてしまいました。文字コードの判定の仕方が分からなかったんです。

PLT Scheme では、入出力のエンコーディングを明示的に指定して変換することは出来るんですが、自動判定の機能が無いみたいなんです。

野良 HTML を相手にしたいので、HTTP ヘッダや META タグでのエンコーディング指定は当てに出来ない、というのが大前提です。

なので、自動判定の仕組みを自分で作る必要が出てきました。

考えた結果、入力側のエンコーディング方式として想定されるもののリストを予め用意しておき、総当り的に変換を試みる方法が浮かびました。


とりあえず、文字コード変換には scheme/port というライブラリの reencode-input-port という関数を使うことにします。これは、入力ポートを、指定エンコーディングから UTF-8 に変換した新しい入力ポートを返す関数です。

呼び出し例:

(reencode-input-port in "CP932" #f)

実際に使ってみて分かったんですが、変換に失敗する場合でも、この関数を評価した時点では何のエラーも起こりません。返された入力ポートを読み込むうちにエラーが出るんです。

したがって、変換とは別の場所 (読み込み・パースを行う場所) でエラー・ハンドリングを行い、再び前の変換の部分に戻る仕組みを考える必要があります。

このようになりました:

(require scheme/port
         scheme/control)

(define encs '("CP932" "EUC-JP" "ISO-2022-JP"))

(define (rewind in)
  (or (zero? (file-position in))
      (file-position in 0))
  in)

(define (to-utf8 in)
  (reset
   (for-each (lambda (enc)
               (shift k
                 (values (reencode-input-port (rewind in) enc #f)
                         (lambda () (k #f)))))
             encs)
   (values (rewind in) #f)))

(define (in-utf8 f in)
  (define (try in2 next)
    (with-handlers ((exn:fail?
                     (lambda (x)
                       (close-input-port in2)
                       (call-with-values next try))))
      (f in2)))
  (call-with-values
      (lambda () (to-utf8 in))
    try))

部分継続を取り出すのに reset と shift のペアを用いるパターンは zipper の時にもお話しました。ここでは shift で切り取った k を lambda に包んで、失敗の際に次の候補を試すための継続として返しています。

テスト:

(call-with-input-file tmp-file
  (lambda (in)
    (in-utf8 (lambda (in)
               (display "Title: ")
               (display (cadr (regexp-match #rx"<TITLE>(.+?)</TITLE>" in)))
               (newline))
             in)))

結果:

Title: Title: 今夜も&hearts;うさちゃんピース

最初の試行 (Shift-JIS) では一つ目の display の後でエラーが生じ、そして何事も無かったように次のエンコーディングが試されて成功した、ということが読み取れます。

in-utf8 の引数の関数内では、変換エラーが起こる可能性を全く気にせずにプログラミングできる、というわけです。

実際には、in-utf8 には関数値を返すだけの、副作用を生じない関数を渡すべきでしょう。変換エラーが生じた場合、途中までの計算は全て破棄され、正しい結果だけを得ることができて便利だと思います。


ただ、この実装には若干の問題があります。変換時のエラーとそれ以外のエラーとを区別していないのです。reencode-input-port が固有の例外型を投げてくれないので、一般のエラーと同じ扱いで (exn:fail?) 補足しているためです。exn-message 関数でエラーメッセージをチェックするなどの工夫が必要でしょう。