<WordPress>DOMを使ってHTMLをロードしてセーブするのが何気に難しかった件

 こんにちは、その日暮らしです。

 WordPressのthe_contentフィルターフックを使って投稿ページや固定ページの本文を操作することってありますよね。

 このとき、操作の内容によってはDOMにHTMLをロードしてなにかの操作を行った後にセーブすることになると思うのですが、これが何気に難しく先日少しハマってしまいました。

 当記事では、そのときの顛末をメモがてら記事にしようと思います。

[PR]
目次

とりあえずの正解のようなもの?

 いまのところ、以下のコードで落ち着いています。

// 何気に難しいHTMLのロードとセーブ
function my_dom_load_and_save($html) {
    // HTMLの文字コードをUTF-8からHTML-ENTITIESに変換する。
    // ↓この使い方はPHP8.2で非推奨となったのでmb_encode_numericentityで置き換える。
    // $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
    $html_encoded = mb_encode_numericentity($html, [0x80, 0x10ffff, 0, 0x1fffff], 'UTF-8');
    // HTMLをDOMにロードする。
    // ロード時に<HTML>や<BODY>や<!DOCTYPE>が追加されないようにする。
    // その代わり一時的に<div></div>でラップする必要あり。
    $doc = new DOMDocument();
    @$doc->loadHTML("<div>$html_encoded</div>", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    // ラップした<div></div>をはがす。
    $cont = $doc->getElementsByTagName('div')->item(0);
    while ($doc->firstChild) {
        $doc->removeChild($doc->firstChild);
    }
    while ($cont->firstChild) {
        $doc->appendChild($cont->firstChild);
    }

    // 
    // DOMを使ったなにかしらの操作
    // 

    // DOMをHTMLにセーブする。
    $html_saved = $doc->saveHTML();
    // HTMLの文字コードをHTML-ENTITIESからUTF-8に変換する。
    // ↓この使い方はPHP8.2で非推奨となったのでmb_decode_numericentityで置き換える。
    // $html = mb_convert_encoding($html, 'UTF-8', 'HTML-ENTITIES');
    $html_decoded = mb_decode_numericentity($html_saved, [0x80, 0x10ffff, 0, 0x1fffff], 'UTF-8');
    return $html_decoded;
}
add_filter('the_content', 'my_dom_load_and_save');

正解のようなものにたどり着くまで

 HTML「<p>あいうえお</p><br><p>かきくけこ</p>」をロードしてセーブすることを考えます。

 単にHTMLをDOMにロードしてすぐにセーブするだけであれば、ロード前とセーブ後のHTMLは等価なはずです。

 まず、単にロードしてセーブする場合のコードです。

    $doc = new DOMDocument();
    @$doc->loadHTML($html);

    // 
    // DOMを使ったなにかしらの操作
    // 

    $html = $doc->saveHTML();

 ロード前のHTMLは、以下の通りです。

<p>あいうえお</p><br><p>かきくけこ</p>

これが、セーブ後には以下のようになってしまいます。

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body><p>ã‚ã„ã†ãˆãŠ</p><br><p>ã‹ããã‘ã“</p></body></html>

 <html><body>と</body></html>とで本来のHTMLが囲まれ、先頭にはDOCTYPE宣言が付加されてしまっています。これは、DOMDocumentクラスのloadHTMLメソッドにオプションLIBXML_HTML_NOIMPLIEDとLIBXML_HTML_NODEFDTDを指定することで対処できます。LIBXML_HTML_NOIMPLIEDは<html><body></body></html>の追加を無効にするオプションで、LIBXML_HTML_NODEFDTDはDOCTYPE宣言の追加を無効にするオプションです。

 また、日本語部分が文字化けしているように見えるのは、HTMLの文字コードがUTF-8であるのにもかかわらず、loadHTMLメソッドが文字コードをISO-8859-1と想定して読み込んだ結果です。これは、loadHTMLメソッドを呼び出す前に関数mb_encode_numericentityを使ってUTF-8からHTML-ENTITIESへエンコードすることで対処できます。なお、DOMDocumentクラスのsaveHTMLメソッドを呼び出した後にはHTML-ENTITIESからUTF-8へデコードする必要があります。

    $html_encoded = mb_encode_numericentity($html, [0x80, 0x10ffff, 0, 0x1fffff], 'UTF-8');
    $doc = new DOMDocument();
    @$doc->loadHTML($html_encoded, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);

    // 
    // DOMを使ったなにかしらの操作
    // 

    $html_saved = $doc->saveHTML();
    $html_decoded = mb_decode_numericentity($html_saved, [0x80, 0x10ffff, 0, 0x1fffff], 'UTF-8');

 こうした場合のセーブ後のHTMLは、以下のようになります。

<p>あいうえお<br><p>かきくけこ</p></p>

 よく見ると、「あいうえお」の直後にあるべき閉じタグ</p>がありません。その代わりHTMLの一番後ろに</p>が付加されています。

 これは、DOMで使われているLibXML(パーサ?)の仕様らしいです。以下のページの、

2つ目のアンサーによると、

LibXML requires a root node, and is treating the first element it finds as the root node, deleting the (incorrectly located) closing tag it finds half-way through, and then outputting the closing tag of the first element it found at the end of the document. It's logical when you see it from (Lib)XML's perspective.

https://stackoverflow.com/questions/29493678/loadhtml-libxml-html-noimplied-on-an-html-fragment-generates-incorrect-tags

とのことで、DeepLで翻訳すると「LibXMLはルートノードを必要とし、最初に見つけた要素をルートノードとして扱い、途中で見つけた(不適切な位置にある)閉じタグを削除し、最初に見つけた要素の閉じタグを文書の最後に出力しているのです。(Lib)XMLの視点で見ると論理的ですね。」とのこと。

 loadHTMLメソッドでオプションLIBXML_HTML_NOIMPLIEDを指定する場合には、なにかしらのタグで元のHTMLを囲っておく必要がありそうです。

 このページの1つ目のアンサーを見ると、回避策として、<div></div>で囲ったHTMLをloadHTMLメソッドで一旦ロードしその後<div></div>の囲いをはずす、といったコードが載っているのでこのコードを使って書き直します。

    // HTMLの文字コードをUTF-8からHTML-ENTITIESに変換する。
    // ↓この使い方はPHP8.2で非推奨となったのでmb_encode_numericentityで置き換える。
    // $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
    $html_encoded = mb_encode_numericentity($html, [0x80, 0x10ffff, 0, 0x1fffff], 'UTF-8');
    // HTMLをDOMにロードする。
    // ロード時に<HTML>や<BODY>や<!DOCTYPE>が追加されないようにする。
    // その代わり一時的に<div></div>でラップする必要あり。
    $doc = new DOMDocument();
    @$doc->loadHTML("<div>$html_encoded</div>", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    // ラップした<div></div>をはがす。
    $cont = $doc->getElementsByTagName('div')->item(0);
    while ($doc->firstChild) {
        $doc->removeChild($doc->firstChild);
    }
    while ($cont->firstChild) {
        $doc->appendChild($cont->firstChild);
    }

    // 
    // DOMを使ったなにかしらの操作
    // 

    // DOMをHTMLにセーブする。
    $html_saved = $doc->saveHTML();
    // HTMLの文字コードをHTML-ENTITIESからUTF-8に変換する。
    // ↓この使い方はPHP8.2で非推奨となったのでmb_decode_numericentityで置き換える。
    // $html = mb_convert_encoding($html, 'UTF-8', 'HTML-ENTITIES');
    $html_decoded = mb_decode_numericentity($html_saved, [0x80, 0x10ffff, 0, 0x1fffff], 'UTF-8');

すると、セーブ後のHTMLは以下のようになり、ロード前のHTMLと等価なHTMLが得られます。

<p>あいうえお</p><br><p>かきくけこ</p>

 めでたし、めでたし、でした。


 以上、「<WordPress>DOMを使ってHTMLをロードしてセーブするのが何気に難しかった件」でした。

コメント

コメントする

コメントは日本語で入力してください(スパム対策)。

CAPTCHA

目次