Safari 3 の XPath のバグ2007年11月10日 13時52分

Mac OS X v10.5 Leopard 上では一足早く正式版が公開された Safari 3。JavaScript でのゲッタとセッタのサポートや DOM 3 XPath の実装など、新機能が山盛りです。しかし、Safari 3.0.4 の DOM 3 XPath 実装にはいくつかバグがあるので、それらをここで取り上げてみようと思います。

逆方向軸に関するコンテキスト位置

XPath 1.0 では、式を角括弧で囲んだ述語を用いることで、ノードセットから結果を絞り込んでいくことが可能です。例えば、child::*[position() mod 2 = 0] なら、子要素のうち偶数番目のものだけを選び出すといった具合です。

ここで、position 関数により得られるコンテキスト位置は、元のノードセットを文書順で並べ替えたとき、現在処理中のノードが何番目になるかを示しています。ただし、ancestor、ancestor-or-self、preceding、preceding-sibling の各軸は逆方向軸と呼ばれ、これらの軸に対する述語内では、ノードセットを文書順の逆順で並べ替えたときの順番からコンテキスト位置が得られます。つまり、

<a>
  <b>
    <c/>
  </b>
</a>

という XML 文書があったとき、/descendant::c/ancestor::*[1] は、a 要素と b 要素を文書順の逆順で並べ替えたときの最初のもの、すなわち b 要素を指します。

ところが、Safari では、逆方向軸に対する述語中のコンテキスト位置の算出方法が間違っており、ancestor、ancestor-or-self、preceding-sibling 軸に対する述語中でも文書順でコンテキスト位置を算出してしまいます。preceding 軸に対しても、文書順ではないですが間違った順序でコンテキスト位置が算出されます。すなわち、上の例では a 要素を返してしまうということです。

このバグを回避し、ひとつの XPath 式で Safari でもほかのブラウザと同じ結果を得られるようにするためには、ノードセットをあらかじめ文書順に並べ替えておくといった方法が考えられます。(/descendant::c/ancestor::*)[last()] のように、述語の対象をロケーションステップから基本式へと変えることで、基本式で得られたノードセットを文書順に並べ替えてから述語が適用されるようになります。順番が逆になるので、1 番目としていたノードは、last 関数を使い最後のものと指定することになります。

ただし、このアプローチは常にうまくいくわけではありません。例えば、以下の XML 文書を考えてみてください。

<a>
  <b xml:id="first"><c/></b>
  <b xml:id="second"><c/></b>
  <b xml:id="third"><c/></b>
</a>

ここで、/descendant::c/ancestor::*[1] は、各 c 要素の最初の祖先要素を集めたもの、すなわち first、second、third の 3 要素からなるノードセットになります。しかし、(/descendant::c/ancestor::*)[last()] とすると、各 c 要素の祖先要素を集めたものの中で最後のもの、すなわち third のみからなるノードセットになってしまいます。

id 関数に指定する文字列

id 関数を使えば特定の id を持つ要素を一気に取得できますが、Safari では要素をうまく取得できないことがあります。例えば、文書中に a、b という id を持つ要素があったとして、id('a') としても空のノードセットしか返ってきません。id('a b') とすると、本来ならば a、b の 2 要素を含むノードセットが返るはずが、a のみが含まれるノードセットが返ってきます。

この問題は、文字列の最後に空白文字を付け足すことで解決します。つまり、id('a ')id('a b ') とすれば、期待通りの結果が得られます。また、引数がノードセットのときには、このような問題は発生しません。

なお、id 関数に関しては、Opera でも id 関数のみからなる XPath 式がエラーになることがあるといった問題が存在するようです。

substring-after 関数

substring-after(str1, str2) は、str1str2 を含んでいれば str2 が見つかった位置に str2 の長さを足した位置以降の文字列を、含んでいなければ空文字列を返します。しかし、Safari では、str2 が見つかった位置を無視して、常に str1 のうち str2 の長さ分の位置以降の文字列を返してしまいます。substring-after('abcde', 'd') が、本来ならば 'e' となるところを、Safari は 'bcde' としてしまうといった具合です。

これを避けるためには、substring-after 関数を、substring(str1, string-length(substring-before(str1, str2)) + string-length(str2) + 1, contains(str1, str2) * string-length(str1)) のように書き換えてやる必要があります。あるいは、バグをもってバグを制すというのなら、substring(substring-after(str1, str2), string-length(substring-before(str1, str2)) + 1) のように、もう少し短くも書けます。

補記

これらのバグのうち、逆方向軸に関するコンテキスト位置と substring-after 関数とについては、amachang さんが作成した XPath 機能テストにより明らかになりました。また、いずれのバグも既に WebKit Bugzilla に登録され、修正済みとなっています (Bug 15380Bug 15436Bug 15437)。最新の WebKit Nightly Builds を使えば問題は起こらないはずですし、Safari においても、Mac OS X のアップデートなどに伴って修正されるかもしれません。

なお、これらはいずれも DOM 3 XPath のインターフェースを用いて XPath を扱ったときの問題であり、XSLT 中に書かれた XPath では発生しません。これは、WebKit が DOM 3 XPath に関する処理を自前で行っているのに対し、XSLT の処理は libxslt に任せているからだと思われます。