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

2021年11月9日

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

 WordPressのthe_contentフィルターフックに関数を登録し引数を介して渡ってきたHTMLを操作することってありますよね。

 このとき、DOMを使ってHTMLをロードしてセーブすることになると思うのですが、これが何気に難しく先日少しハマってしてしまいました。

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

とりあえずの正解?

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

// 何気に難しいDOMを使ったHTMLのロードとセーブ
function my_dom_load_and_save($content) {
    $html = str_replace('&', '&', $content);
    $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
    $doc = new DOMDocument();
    @$doc->loadHTML("<div>$html</div>", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    $cont = $doc->getElementsByTagName('div')->item(0);
    while ($doc->firstChild) {
        $doc->removeChild($doc->firstChild);
    }
    while ($cont->firstChild) {
        $doc->appendChild($cont->firstChild);
    }
    // 
    // DOMを使ったなにかしらの操作
    // 
    $html = $doc->saveHTML();
    $html = mb_convert_encoding($html, 'UTF-8', 'HTML-ENTITIES');
    return $html;
}
add_filter('the_content', 'my_dom_load_and_save');

正解にたどり着くまで

 HTML「<p>あいうえお</p><p>&lt;html></p><p>かきくけこ</p>」をロードしてセーブすることを考えます。the_contentフィルターフックに登録した関数に渡ってくるHTMLの文字コードはUTF-8です。また、WordPressで記事中に「<html>」と書くと実際には内部で「&lt;html>」と変換されて扱われます。ここでは、文字コードや「&lt;」の扱いが正しく行われることをとりあえずの目標にします。

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

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

    $doc = new DOMDocument();
    @$doc->loadHTML($content);
    // 
    // DOMを使ったなにかしらの操作
    // 
    $html = $doc->saveHTML();

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

<p>あいうえお</p><p>&lt;html></p><p>かきくけこ</p>

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

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body><p>&atilde;&#129;&#130;&atilde;&#129;&#132;&atilde;&#129;&#134;&atilde;&#129;&#136;&atilde;&#129;&#138;</p><p>&lt;html&gt;</p><p>&atilde;&#129;&#139;&atilde;&#129;&#141;&atilde;&#129;&#143;&atilde;&#129;&#145;&atilde;&#129;&#147;</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_convert_encodingを使ってHTMLの文字コードをUTF-8からHTML-ENTITIESに変換することで対処できます。なお、DOMDocumentクラスのsaveHTMLメソッドを呼び出した後にはHTMLの文字コードをHTML-ENTITIESからUTF-8へと逆に変換する必要があります。

    $html = mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8');
    $doc = new DOMDocument();
    @$doc->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    // 
    // DOMを使ったなにかしらの操作
    // 
    $html = $doc->saveHTML();
    $html = mb_convert_encoding($html, 'UTF-8', 'HTML-ENTITIES');

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

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

 ロード前に「&lt;」だったものが「<」に変換されています。これでは、このHTMLをブラウザで表示すると「<html>」がHTMLタグとして扱われてしまい正しく表示されません。これは、以下のようにloadHTMLメソッドを呼び出す直前でHTML中の「&」を「&amp;」に置換しておくことで対処できます。

    $html = str_replace('&', '&amp;', $content);
    $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
    $doc = new DOMDocument();
    @$doc->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    // 
    // DOMを使ったなにかしらの操作
    // 
    $html = $doc->saveHTML();
    $html = mb_convert_encoding($html, 'UTF-8', 'HTML-ENTITIES');

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

<p>あいうえお<p>&lt;html></p><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 = str_replace('&', '&amp;', $content);
    $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
    $doc = new DOMDocument();
    @$doc->loadHTML("<div>$html</div>", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    $cont = $doc->getElementsByTagName('div')->item(0);
    while ($doc->firstChild) {
        $doc->removeChild($doc->firstChild);
    }
    while ($cont->firstChild) {
        $doc->appendChild($cont->firstChild);
    }
    // 
    // DOMを使ったなにかしらの操作
    // 
    $html = $doc->saveHTML();
    $html = mb_convert_encoding($html, 'UTF-8', 'HTML-ENTITIES');

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

<p>あいうえお</p><p>&lt;html></p><p>かきくけこ</p>

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


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


この記事を書いた人

プロフィール

 その日暮らし(プレカリアートor失業者)

 こんにちは、プレカリアートあるいは失業者のその日暮らしです/地方国立大理系院卒→大手大企業就職→ソフト開発二十年超→メンタル壊して退職→ちょっと回復→資格取得頑張る(簿記3級と応用情報は合格でデスペはギブアップ)→コロナ禍で再就職無理→離婚orz→実家へ出戻ってこどおじ化→WordPressの勉強のためブログに挑戦/そんな訳でブログは始めたばかりですが日々いろんなことを試して得た知識を投稿していこうと思ってます/以上