2011/08/16

はじめてでも安心 SXML入門

HTMLやXMLの文章を扱っていると、気楽にこんな操作をしたいケースが間々あります。

  1. 親に応じて処理を分けたい(例: <title> 要素に対して、その親が <chapter> なら処理 A 、 <section> なら処理 B をしたい)
  2. ある属性を持つ要素だけ処理したい(例: lang="en"<p> 要素だけ抜き出したい)
  3. 同じレベルの次の要素に応じて処理をしたい(例:表の各行の色を縞々にするため、 <table> 直下の <tr> に交互に属性 bgcolor="#cccccc"bgcolor="#ffffff" をつけたい)
(いずれもブラウザへの表示であれば CSS で実現できますが、いまは出力も XML データとして欲しい場合の話です。)

XML データの本質が木構造であることを思い出すと、この山括弧が S 式にさえなっていれば、 Scheme の関数でどうにでも操作できる気がしてきます。SXML は、まさにそのような夢をかなえてくれるものです。詳しくは Wikipedia で。ここでは、Gauche に入っている XML から SXML へのパーザーと SXML の操作関数、山括弧表記へのシリアライザを使って、SXML データを操る方法をまとめます。

XML データを SXML へ

何も考えずに ssax:xml->sxml というユーティリティを使います。自分の目的に応じた XML パーザを作る仕組みも提供されているのですが、私は使ったことがありません。

(use sxml.ssax)

(call-with-input-file "test.xml"
  (lambda (port)
    (with-module sxml.ssax
      (ssax:xml->sxml port '()))))

ただ一点だけ、このユーティリティを黙って使う際にぶつかる壁があって、それは文字実体参照です。定義済み実体しか解釈してくれません。そのため、たとえば &nbsp; が混ざっているとはじかれます。 &nbsp; は HTML でよく使われるので、これはいささか不便です。

ssax:xml->sxml には外部の文字実体参照を指定できる仕組みがないので戸惑うところですが、 sxml.ssax のソースを見ると ssax:predefined-parsed-entities というのがあって、これをつつけばよさそう。幸い、 Gauche には with-module という仕組みがあるので、これを使って文字実体参照のデフォルトを「上書き」してしまいます。

(use sxml.ssax)

(call-with-input-file "test.xml"
  (lambda (port)
    (with-module sxml.ssax
      (fluid-let ((ssax:predefined-parsed-entities
                  (list '(nbsp . " ") '(amp . "&") '(lt . "<") '(gt . ">")
                        '(quot . "\") '(apos . "'")))
        (display (ssax:xml->sxml port '()))))))

これで次のようなファイル "test.xml" が、

<body>
  <p>ab&nbsp;c</p>
</body>
次のような S 式になります。
(*TOP* (body (p "ab c")))

肩慣らし:要素の取得

とりあえず、これから使うサンプルの XML データを用意します。

(define newbooks "
<table>
  <thead>話題の新刊</thead>
  <tbody>
    <tr><th>書名</th><th>価格</th></tr>
    <tr><td>抽象によるソフトウェア設計</td><td>4500円</td></tr>
    <tr><td>インターネットのカタチ</td><td>1900円</td></tr>
    <tr><td>Scheme修行</td><td>2800円</td></tr>
    <tr><td>Coders at Work</td><td>2800円</td></tr>
  </tbody>
</table>
")

また、 XML の山括弧で表現されたテキストを SXML のデータに変換する関数をでちあげます(先に使ったコードと本質的に同じもの)。

(define (string->sxml str)
  (call-with-input-string str
    (lambda (port)
      (ssax:xml->sxml port '()))))

これから何度か利用するので、テーブル newbooks を SXML に変換したものに名前をつけておきます。これは恣意的に root という名前にします。

(define root (string->sxml newbooks))

準備はここまで。肩慣らしにテーブルからヘッダ部分を取り出してみましょう。実は XPath のような指定が使えます。

gosh> ((sxpath "table/thead") root)
=> ((thead "話題の新刊"))

"table/thead"」の部分には、求めるノードへのパスを XPath に似た(ほとんどそのまんまの)構文で記述できます。その記述に関数 sxpath を適用すると、ノードの集合からノード(記述したパスを満たすもの)を取り出す関数(コンバーターと呼びます)が返ってくるので、それを root に適用すれば求めるノードが手に入るという仕掛けです。

よりScheme風に

関数 sxpath は便利で多機能なのですが、クエリを組み立てるのに XPath の文法を知らないといけないので、ここではもうちょっと Scheme ふうのやり方を紹介します。

gosh> ((node-closure (ntype-names?? '(thead))) root)
=> ((thead "話題の新刊"))

この方法も、「ノードの集合からノードを取り出すコンバーター関数を作って、それを root に適用する」という方針は sxpath バージョンと同じです。コンバーターの作り方がちょっと違います。 node-closure という関数は、引数として述語をとり、その述語を満たすようなノードを「再帰的に」とってくるコンバーターを作ります。「再帰的に」というのは、述語を満たすノードをSXML木の根っこを起点に探すだけでなく、あらゆる子孫(大本の根っこも含みます)を起点に探すという意味です。この例では、「thead という名前かどうか?」( (ntype-names?? '(thead)) )という述語を指定しているので、とにかく木全体からその名前のノードをとってきます。

ところで、得られるノードは1つとは限りません。

gosh> ((node-closure (ntype-names?? '(td))) root)
=>((td "抽象によるソフトウェア設計") (td "4500円") (td "インターネットのカタチ") (td "1900円")
   (td "Scheme修行") (td "2800円") (td "Coders at Work") (td "2800円"))

ここで気にかけておくべきなのは、こうして REPL から結果がリストとして返ってくるとまるで結果のノードからなる新しいリストが得られたように錯覚するけれど、これらはちゃんと root の部分木になっているということです。つまり、これらの結果から、例えば「親」を知ることができます。

gosh> (define tds ((node-closure (ntype-names?? '(td))) root))
gosh> (((sxml:parent (ntype?? '*any*)) root) (car tds))
=> ((tr (td "抽象によるソフトウェア設計") (td "4500円")))

(ntype?? '*any*) は「何でもいい」という述語です。この式は、親( sxml:parent )なら何でもいいからとってこい、という意味になります。

くどくいようですが、上記の (car tds) は表現としては (td "抽象によるソフトウェア設計") というリストだけれども、そういう表現のリストに対して上記の結果が得られるのではありません。(当たり前の話だけど、最初はやっぱり見た目に惑わされがち。)

gosh> (((sxml:parent (ntype?? '*any*)) root) '(td "抽象によるソフトウェア設計"))
=> ()

実践編:ノードを追加する

SXML は木にすぎないので、その一部を変更するような操作も、 Scheme のような言語であればわりと直感的に書けます。ここでは、先の書籍一覧のテーブルの各行に「税」という項目を追加してみます。もちろん税額は、すでにテーブルに含まれている価格から求めます。そこでまず、価格の文字列から税額の文字列を作る関数を定義します。ここでは安直に正規表現で。

;; string -> string
(define (tax-price str)
  (rxmatch-if (#/([\d]*)円/ str)
      (m price)
    #`",(* 5/100 (x->integer price))円"
    #f))

次に、この tax-price 関数を使って「 <tr> ノードから税額を要素に持つ <td> ノードを作る関数」を定義します。なお、 SXML の一部をいじる関数を作るときは、このようにノードからノード(あるいはノードの集合)への関数として定義しておきましょう。そのほうが SXPath 用の適用関数をいろいろ利用できて便利です。

;; node -> node
(define (tax-node node)
  (cond ((tax-price (sxml:string-value node)) => (pa$ list 'td))
        (((sxpath "th") tr) '(th "税"))
        (else '())))

あとは各行に tax-node で得られるノードを要素として追加するだけですが、要素を追加する関数は Gauche にはないようなので、既存の要素 (sxml:content-raw tr) と新しいノード (tax-node tr) とを連結したもので既存のノードを置き換えます。木の一部を破壊的に変更する必要があるので、きちんと避難しましょう(『 Scheme 修行』ね)。

(let1 root (string->sxml newbooks)
  (for-each (lambda (tr)
              (sxml:change-content! tr `(,@(sxml:content-raw tr) ,(tax-node tr))))
            ((node-closure (ntype-names?? '(tr))) root))
  root)

=> (*TOP* (table (thead "話題の新刊") (tbody
      (tr (th "書名") (th "価格") (th "税"))
      (tr (td "抽象によるソフトウェア設計") (td "4500円") (td "225円"))
      (tr (td "インターネットのカタチ") (td "1900円") (td "95円"))
      (tr (td "Scheme修行") (td "2800円") (td "140円"))
      (tr (td "Coders at Work") (td "2800円") (td "140円")))))

軸とか

テーブルのいちばん左の列をヘッダのように使いたいことがあります。各 <tr> ノードの最初の要素に bgcolor="#cccccc" とつけるにはどうしたらいいでしょうか。

ぱっと思いつくのは、先行する要素がなければ属性を追加する、という処理です。 SXML では、 XPath のように、親や子孫、先行や後続といった「軸」が利用できます。これを使って、各行の先頭の要素に背景色の属性を付けてみましょう。

(let1 root (string->sxml newbooks)
  (for-each (lambda (td)
              (if (null? (((sxml:preceding-sibling sxml:element?) root) td))
                  (sxml:set-attr! td '(bgcolor "#cccccc"))))
            ((node-closure (ntype-names?? '(td th))) root))
  root)

使っている軸は、先行する兄弟の軸 sxml:preceding-sibling です。 <td> 要素のそれぞれについて、先行する兄弟の要素がいるかどうかチェックし、いなかったら属性を設定しています。要素の軸は、その要素が含まれる木(もちろん部分木のことも)に対して決まるものなので、 ((sxml:preceding-sibling sxml:element?) root) のように木を明示する必要があります。

=> (*TOP* (table (thead "話題の新刊") (tbody
      (tr (th (|@| #0=(bgcolor "#cccccc")) "書名") (th "価格"))
      (tr (td (|@| #0#) "抽象によるソフトウェア設計") (td "4500円"))
      (tr (td (|@| #0#) "インターネットのカタチ") (td "1900円"))
      (tr (td (|@| #0#) "Scheme修行") (td "2800円"))
      (tr (td (|@| #0#) "Coders at Work") (td "2800円")))))

なお、ここではわざわざ軸を使っていちばん左の列を選択しましたが、 1つめの要素、のような指定の仕方で取得することももちろんできます。

(let1 root (string->sxml newbooks)
  (for-each (lambda (tr)
              (sxml:set-attr!
               (car ((node-pos 1) ((node-closure (ntype-names?? '(td th))) tr)))
               '(bgcolor "#cccccc")))
            ((node-closure (ntype-names?? '(tr))) root))
  root)

山括弧にするには

良し悪しは別にして、やはり山括弧に戻せないといろいろ不便です。とくに html をいじっている場合には。Gauche のマニュアルを見ていると sxml->html という名前が付いた関数が二種類(sxml:sxml->htmlsrl:sxml->html)ありますが、ここで使うべきは srl:sxml->html のほうです。

(use sxml.serializer)

(srl:sxml->html
 (let1 root (string->sxml newbooks)
   (for-each (lambda (tr)
               (sxml:set-attr!
                (car ((node-pos 1)
                      ((node-closure (ntype-names?? '(td th))) tr)))
                '(bgcolor "#cccccc")))
             ((node-closure (ntype-names?? '(tr))) root))
   root))

よろしくインデントされた結果が返されます。

<table>
  <thead>話題の新刊</thead>
  <tbody>
    <tr>
      <th bgcolor="#cccccc">書名</th>
      <th>価格</th>
    </tr>
    (略)
  </tbody>

</table>

最後に宣伝

『Scheme修行』

ちなみに、この記事のタイトルは『はじめてでも安心コスプレ入門』から

No comments :