2024-09-23

Emacs LispでXMLを解析する方法(名前空間あり)

libxml-parse-xml-region

「emacs lisp parse xml」などでWeb検索すると真っ先に出てくるのはEmacs Lispのマニュアルでしょう。

Parsing HTML/XML (GNU Emacs Lisp Reference Manual)

そこではlibxml-parse-xml-regionという関数で解析が出来るというようなことが書かれています。早速試してみましょう。

まずは次のXMLファイルを用意します。これは今適当にでっち上げたXMLで特に意味はありません。これをexample.xmlというファイル名で保存します。

<?xml version="1.0" encoding="UTF-8"?>
<my-nanikano-data
    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
    xmlns:dc="http://purl.org/dc/elements/1.1/">
  <my-file-list>
    <file name="a0001.wav">
      <text><p xmlns="http://www.w3.org/1999/xhtml">これはテストですてすとてすと<strong>0001</strong>です。</p></text>
      <rdf:Description rdf:about="" xmp:Rating="5" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
        <dc:creator>太郎</dc:creator>
      </rdf:Description>
    </file>
    <file name="b0001.wav">
      <text><p xmlns="http://www.w3.org/1999/xhtml">こんにちは<strong>0002</strong>です!</p></text>
      <rdf:Description rdf:about="">
        <dc:creator>花子</dc:creator>
      </rdf:Description>
    </file>
  </my-file-list>
</my-nanikano-data>

そしてlibxml-parse-xml-regionを使って解析してみます。

(with-temp-buffer
  (insert-file-contents "example.xml")
  (libxml-parse-xml-region))

結果は次のようになりました。

(my-nanikano-data nil
                  (my-file-list nil
                                (file ((name . "a0001.wav"))
                                      (text nil
                                            (p nil
                                               "これはテストですてすとてすと"
                                               (strong nil "0001")
                                               "です。"))
                                      (Description
                                       ((about . "") (Rating . "5"))
                                       (creator nil "太郎")))
                                (file ((name . "b0001.wav"))
                                      (text nil
                                            (p nil "こんにちは"
                                               (strong nil "0002")
                                               "です!"))
                                      (Description ((about . ""))
                                                   (creator nil "花子")))))

名前空間が消えてしまっています。これはいけません。これをベースに内容を修正して書き戻したらmy-nanikano-dataを読み込むアプリが正しく動かなくなってしまいます(まぁ、そんなものはありませんが)。

「emacs libxml namespace」でWeb検索するとこの問題に対するバグ報告と思わしきものがヒットしますが、libxml2のバグでEmacsのバグじゃ無いよ、ということでクローズされているみたいです(#59537 - `libxml-parse-xml-region` strips out the namespace information, and namespace prefix in the DOM representation - GNU bug report logs)。そんなわけないでしょう(笑)。libxml2はノードの解析結果をxmlNode構造体で返すようですが、libxml-parse-xml-regionの実装はその中のnsを一切触っていませんからね。

まぁ、バグを見つける→バグ報告を見つける→よく分からない理由で却下されている、という流れはEmacsでは良くあることです。Emacsは「あるがまま」の状態で提供されているわけで、自分で直して使えない人にはお勧めできません。

とは言えここはC言語で書かれている部分なので修正のハードルは高いですね。Emacs Lispレベルで何とかならないでしょうか?

xml-parse-file (オプション無し)

探してみるとxml-parse-fileというものが見つかりました(xml.el内)。早速使ってみましょう。

(xml-parse-file "example.xml")
((my-nanikano-data
  ((xmlns:rdf . "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
   (xmlns:dc . "http://purl.org/dc/elements/1.1/"))
  "\n  "
  (my-file-list nil "\n    "
                (file ((name . "a0001.wav")) "\n      "
                      (text nil
                            (p
                             ((xmlns . "http://www.w3.org/1999/xhtml"))
                             "これはテストですてすとてすと"
                             (strong nil "0001") "です。"))
                      "\n      "
                      (rdf:Description
                       ((rdf:about . "") (xmp:Rating . "5")
                        (xmlns:xmp . "http://ns.adobe.com/xap/1.0/"))
                       "\n        " (dc:creator nil "太郎") "\n      ")
                      "\n    ")
                "\n    "
                (file ((name . "b0001.wav")) "\n      "
                      (text nil
                            (p
                             ((xmlns . "http://www.w3.org/1999/xhtml"))
                             "こんにちは" (strong nil "0002") "です!"))
                      "\n      "
                      (rdf:Description ((rdf:about . "")) "\n        "
                                       (dc:creator nil "花子")
                                       "\n      ")
                      "\n    ")
                "\n  ")
  "\n"))

パッと見良さそうに見えます。名前空間の宣言や接頭辞の情報が失われず残っています。空白文字が全部残っているのは気になりますが、まぁ、何とでもなります。

問題は要素名や属性名が接頭辞を含む一つのシンボルで表現されていることです。その接頭辞がどの名前空間名(URI)を表しているか(あるいは省略されている場合にデフォルト名前空間が何か)は自分で調べなければならないでしょう。接頭辞が慣用的に決まっていてそれしか対応する必要が無いならこれでも良いのでしょうが、私はちょっと気になります。

xml-parse-file (parse-ns引数を使用)

改めてxml-parse-fileの関数ドキュメントを見ると、parse-ns引数を非nilにするとQNAMES(修飾名)を展開できると書かれています。早速やってみましょう。

(xml-parse-file "example.xml" nil t)
((("" . "my-nanikano-data")
  ((("http://www.w3.org/2000/xmlns/" . "rdf")
    . "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
   (("http://www.w3.org/2000/xmlns/" . "dc")
    . "http://purl.org/dc/elements/1.1/"))
  "\n  "
  (("" . "my-file-list") nil "\n    "
   (("" . "file") ((("" . "name") . "a0001.wav")) "\n      "
    (("" . "text") nil
     (("http://www.w3.org/1999/xhtml" . "p")
      ((("http://www.w3.org/2000/xmlns/" . "")
        . "http://www.w3.org/1999/xhtml"))
      "これはテストですてすとてすと"
      (("http://www.w3.org/1999/xhtml" . "strong") nil "0001")
      "です。"))
    "\n      "
    (("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "Description")
     ((("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "about") . "")
      (("" . "Rating") . "5")
      (("http://www.w3.org/2000/xmlns/" . "xmp")
       . "http://ns.adobe.com/xap/1.0/"))
     "\n        "
     (("http://purl.org/dc/elements/1.1/" . "creator") nil "太郎")
     "\n      ")
    "\n    ")
   "\n    "
   (("" . "file") ((("" . "name") . "b0001.wav")) "\n      "
    (("" . "text") nil
     (("http://www.w3.org/1999/xhtml" . "p")
      ((("http://www.w3.org/2000/xmlns/" . "")
        . "http://www.w3.org/1999/xhtml"))
      "こんにちは"
      (("http://www.w3.org/1999/xhtml" . "strong") nil "0002")
      "です!"))
    "\n      "
    (("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "Description")
     ((("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "about") . ""))
     "\n        "
     (("http://purl.org/dc/elements/1.1/" . "creator") nil "花子")
     "\n      ")
    "\n    ")
   "\n  ")
  "\n"))

おお、これまで要素名や属性名が入っていた部分がconsセルになっています。 (名前空間名 . ローカル名) という形式になっており、これはXML名前空間仕様における展開名(expanded name)と同じです。これなら接頭辞がどの名前空間名に対応するかを自分で調べなくても良さそうですね。

……と思ったのですが、よく見るとおかしな所があります。

それは (("" . "Rating") . "5") と書かれている部分。これは (("http://ns.adobe.com/xap/1.0/" . "Rating") . "5") でないといけないはずです。

少し追試をしてみましょう。試しに次のようなXMLを作ってみました。

<?xml version="1.0" encoding="UTF-8"?>
<rdf:Description
    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
    xmlns:xmp="http://ns.adobe.com/xap/1.0/"
    rdf:about=""
    xmp:Rating="3"
    xmp:CreateDate="2024-09-23">
  <xmp:Thumbnails>
    <rdf:Alt>
      <rdf:li rdf:parseType="Resource">
        ....
      </rdf:li>
    </rdf:Alt>
  </xmp:Thumbnails>
</rdf:Description>

これを同様に解析してみます。

(xml-parse-file "example-2.xml" nil t)

結果は次の通り。

((("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "Description")
  ((("http://www.w3.org/2000/xmlns/" . "rdf")
    . "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
   (("http://www.w3.org/2000/xmlns/" . "xmp")
    . "http://ns.adobe.com/xap/1.0/")
   (("" . "about") . "") (("" . "Rating") . "3")
   (("" . "CreateDate") . "2024-09-23"))
  "\n  "
  (("http://ns.adobe.com/xap/1.0/" . "Thumbnails") nil "\n    "
   (("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "Alt") nil
    "\n      "
    (("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "li")
     ((("http://www.w3.org/1999/02/22-rdf-syntax-ns#" . "parseType")
       . "Resource"))
     "\n        ....\n      ")
    "\n    ")
   "\n  ")
  "\n"))

属性名である rdf:aboutxmp:Ratingxmp:CreateDate の名前空間名が "" になってしまっています。しかし前にある要素名 rdf:Description や、その後にある要素名(xmp:Thumbnails)や属性名(rdf:parseType)には正しい名前空間名が展開されています。つまり名前空間を宣言した要素に指定されている属性名だけが正しく展開されていないように見えます。

そこでxml.el内にあるxml-parse-fileの実装を読んだところ、原因はxml-parse-tag-1関数内にあることが分かりました。 ;; opening tag と書いてあるあたり、開始タグの解析を行っているあたりです。xml-nsという変数が接頭辞と名前空間名との対応表(alist)になっているようですが、その更新と使用の順序に問題があります。全属性の解析と属性名の展開、名前空間宣言(xmlns:??=)の反映、要素名の展開の順に行われているため、属性名を展開する段階ではまだその要素で行われている名前空間宣言が反映されていません。従って、名前空間宣言をした要素にある属性名は正しく展開されないというわけです。せっかく便利な機能があるのにこれでは使えません。

nxml-parse-file

他にもEmacs内にXMLを解析している部分は無いかと探したところ、nxml-parse-fileという関数を見つけました(nxml-parse.el内)。

早速使ってましょう。

(nxml-parse-file "example.xml")
("my-nanikano-data"
 (((:http://www.w3.org/2000/xmlns/ . "rdf")
   . "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
  ((:http://www.w3.org/2000/xmlns/ . "dc")
   . "http://purl.org/dc/elements/1.1/"))
 "\n  "
 ("my-file-list" nil "\n    "
  ("file" (("name" . "a0001.wav")) "\n      "
   ("text" nil
    ((:http://www.w3.org/1999/xhtml . "p")
     (((:http://www.w3.org/2000/xmlns/ . "xmlns")
       . "http://www.w3.org/1999/xhtml"))
     "これはテストですてすとてすと"
     ((:http://www.w3.org/1999/xhtml . "strong") nil "0001") "です。"))
   "\n      "
   ((:http://www.w3.org/1999/02/22-rdf-syntax-ns\# . "Description")
    (((:http://www.w3.org/1999/02/22-rdf-syntax-ns\# . "about") . "")
     ((:http://ns.adobe.com/xap/1.0/ . "Rating") . "5")
     ((:http://www.w3.org/2000/xmlns/ . "xmp")
      . "http://ns.adobe.com/xap/1.0/"))
    "\n        "
    ((:http://purl.org/dc/elements/1.1/ . "creator") nil "太郎")
    "\n      ")
   "\n    ")
  "\n    "
  ("file" (("name" . "b0001.wav")) "\n      "
   ("text" nil
    ((:http://www.w3.org/1999/xhtml . "p")
     (((:http://www.w3.org/2000/xmlns/ . "xmlns")
       . "http://www.w3.org/1999/xhtml"))
     "こんにちは"
     ((:http://www.w3.org/1999/xhtml . "strong") nil "0002") "です!"))
   "\n      "
   ((:http://www.w3.org/1999/02/22-rdf-syntax-ns\# . "Description")
    (((:http://www.w3.org/1999/02/22-rdf-syntax-ns\# . "about") . ""))
    "\n        "
    ((:http://purl.org/dc/elements/1.1/ . "creator") nil "花子")
    "\n      ")
   "\n    ")
  "\n  ")
 "\n")

問題は特に見当たらないと思います。

特徴:

  • (要素名や属性名の中で)名前空間名(URI等)を表現するのにキーワードを使う(:http://www.w3.org/2000/xmlns/ 等)。
  • 要素名や属性名はconsセル (<名前空間名キーワード> . <ローカル名文字列>) で表現される。ただし、名前空間無しの場合はローカル名単体の文字列となる。
  • デフォルト名前空間の宣言(xmlns=<名前空間名>)の解析結果は ((:http://www.w3.org/2000/xmlns/ . "xmlns") . "<名前空間名>") の形で表現される。 xmlns の部分は :http://www.w3.org/2000/xmlns/ と展開されているのだからローカル名部分は空文字列にでもなりそうなものだが、なぜか "xmlns" が入っている。これだと xmlns: という接頭辞の名前空間を宣言する(xmlns:xmlns=<名前空間名>)のと同じ形になってしまうので、注意が必要。

要素名の展開表現とdom.elの問題

xml-parse-file(parse-nsが非nilの場合)やnxml-parse-fileを使った場合、要素名がconsセルで表現されるようになるわけですが、そうするとdom.elが使えなくなるという問題が発生します。dom.elの各関数には処理対象のノードを指定する引数(nodeやdom)があるわけですが、それらはなぜか引数がノードのリストである可能性を考慮しています。引数nodeがノードのリストであった場合、そのリスト内の最初のノードを処理対象にするコードが各関数に入っています。引数nodeがノードのリストかどうかをチェックするコードは (consp (car node)) という形になっています。nxml-parse-file等を使用して生成されたノードは要素名部分、つまりリストの最初の要素がconsセルになりますから、このチェックにひっかかってしまいます。そしてノードのリストと勘違いされて、その先頭要素が取り出され、構造が破壊されてしまいます。

この問題に対する対策ですが、dom.elは大したことはやっていませんし少々使いづらい所もあるので、自分で独自のノード操作関数を作成するのが一番だと思います。要素の内部表現はいずれで解析した場合でも、 (<要素名> . (<属性alist> . <子供リスト>)) の形になっています。要素名や属性名(属性alistのキー部分)の部分が文字列だったりconsセル (<名前空間名> . <ローカル名>) だったりするだけです。やっていることは単純なので簡単に作れるでしょう。el-easydrawなんかでも最初のうちはdom.elやsvg.elを使用していましたが、今は使わなくなってしまいました。

別の方法としては、解析した後に名前部分を補正してしまう手があります。つまり、ツリーを巡回して名前部分を一つのシンボルなり文字列なりに結合してしまうわけです(:http://www.w3.org/1999/02/22-rdf-syntax-ns\#::Description のように)。そうすればdom.elは引き続き使えます。

まとめ

名前空間をちゃんと扱いたいならnxml-parse-fileが一番良さそうです。この場合、解析結果そのままだとdom.elが使えないので何らかの対処が必要になります。

接頭辞が決め打ちでも構わないような用途なら(parse-ns引数を使わない)xml-parse-fileも使えそうです。parse-ns引数は不具合があるので止めましょう。

名前空間を使わない、もしくは消えても構わないような用途ならlibxml-parse-xml-regionが使えます。

Emacsの中をくまなく探したわけでもありませんし紹介した関数の中もくまなく調べたわけでもありませんので、何か気がついていない問題やより良い方法があるかもしれません。