はじめに (対象読者・この記事でわかること)

この記事は、PHPでHTMLやXMLをパース・操作する開発者、特にDOMDocumentを利用してWebスクレイピングやHTMLコンテンツの生成を行っている方を対象としています。DOMDocumentは非常に強力なツールですが、HTMLエンティティの扱いで予期せぬ挙動に遭遇し、文字化けや意図しない変換に悩まされた経験があるかもしれません。

この記事を読むことで、以下の点がわかるようになります。 - DOMDocumentがHTMLエンティティをどのように扱うかの基本的な挙動。 - DOMDocument::loadHTML()およびDOMDocument::saveHTML()におけるエンティティ関連の問題の原因。 - 文字化けや意図しないエンティティ変換を防ぐための具体的な解決策と実装方法。 - libxml_use_internal_errorshtmlspecialcharsといった関連関数との連携方法。

DOMDocumentを使ったHTML操作で、エンティティ処理の悩みを解消し、より堅牢なコードを書くための手助けとなるでしょう。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - PHPの基本的な文法とプログラミングの経験 - HTML/XMLの基本的な構造と、HTMLエンティティ(例: &, <, é など)についての理解 - 文字エンコーディング(特にUTF-8)に関する基礎知識

DOMDocumentとHTMLエンティティ:基本的な挙動と課題

PHPのDOMDocumentは、HTMLやXMLドキュメントをオブジェクトモデルとして操作するための強力な機能を提供します。これにより、ドキュメントの構造を解析し、要素や属性、テキストノードをプログラム的に追加・変更・削除することが可能になります。しかし、このDOMDocumentを扱う上でしばしば開発者が直面するのが、HTMLエンティティの処理に関する問題です。

HTMLエンティティとは?

HTMLエンティティは、HTML内で特殊な意味を持つ文字(例: <>&)や、キーボードで直接入力できない文字(例: ©、アクセント記号付きの文字 é)を安全に表現するための仕組みです。これらは通常、&で始まり;で終わる形式(例: &lt;, &amp;, &copy;, &eacute;)で記述されます。

DOMDocumentのエンティティ処理の基本

DOMDocumentは、HTML/XMLをパース(解析)する際に、これらのエンティティを適切な文字(内部表現)にデコードしようとします。そして、ドキュメントをHTML文字列として保存(saveHTML())する際に、必要に応じて特定の文字を再度エンティティ化します。

例: - &amp; はパース時に & (アンパサンド) にデコードされます。 - &lt; はパース時に < (小なり記号) にデコードされます。 - &eacute; はパース時に é (eアキュート) にデコードされます。

多くの場合はこの自動的なデコード・エンコードが期待通りに機能しますが、以下のようなシナリオで問題が発生することがあります。

  1. loadHTML()時のエンコーディング問題: 外部から取得したHTMLのエンコーディングが不明確、またはDOMDocumentが誤ったエンコーディングでパースしようとすると、日本語などのマルチバイト文字や特殊なエンティティが正しくデコードされず、文字化けが発生します。
  2. saveHTML()時のエンティティ化の不足/過剰: 特定の文字を明示的にエンティティとして保持したい場合や、逆にsaveHTML()が意図せず文字をエンティティ化してしまい、出力が冗長になることがあります。特に、非ASCII文字が意図せず数値エンティティ(例: &#xE9;)ではなくそのまま出力され、ブラウザでの表示環境に依存してしまうケースがあります。
  3. 既存のエンティティの扱い: 元のHTMLに含まれる &amp; のようなエンティティを、DOMDocumentが & にデコードした後、意図的に &amp; の形でテキストとして保持したい場合に、自動変換が邪魔になることがあります。

これらの課題を解決し、DOMDocumentをより堅牢に扱うための具体的な方法を次に見ていきましょう。

DOMDocumentにおけるHTMLエンティティの具体的な課題と解決策

ここからは、DOMDocumentを使用する際に遭遇する可能性のあるHTMLエンティティ関連の具体的な課題と、それらを効果的に解決するための手法を、コード例を交えながら詳しく解説します。

課題1:loadHTML()による文字化けとエンコーディングの誤認識

最も頻繁に遭遇する問題の一つが、DOMDocument::loadHTML()メソッドでHTML文字列を読み込む際に発生する文字化けです。これは、DOMDocumentがHTMLのエンコーディングを正しく認識できない場合に起こります。特に日本語のようなマルチバイト文字を含むHTMLを扱う際に顕著です。

原因

DOMDocument::loadHTML()は、デフォルトでHTMLの<meta charset="...">タグやHTTPヘッダー情報からエンコーディングを推測しようとしますが、それが不正確な場合や情報が欠落している場合、ISO-8859-1などのデフォルトエンコーディングでパースしようとします。この結果、UTF-8などの適切なエンコーディングで書かれた文字が誤って解釈され、文字化けしてしまいます。

解決策:エンコーディングを明示的に指定する

この問題を解決する最も確実な方法は、loadHTML()を呼び出す前に、HTML文字列の先頭にXML宣言形式でエンコーディング情報を付与することです。これにより、DOMDocumentは指定されたエンコーディングでHTMLをパースするようになります。

また、パース中に発生するHTMLの構文エラーや警告は、libxml_use_internal_errors(true)を使って抑制し、DOMDocumentオブジェクトの生成を妨げないようにすることが推奨されます。

Php
<?php // PHP 8.2 以降では推奨されないが、古い環境では必要になる場合がある。 // mb_internal_encoding("UTF-8"); $html_string_broken = <<<HTML <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>PHP DOMDocumentの文字化けテスト</title> </head> <body> <h1>こんにちは、世界!</h1> <p>特殊文字:&eacute; &copy; 日本語テキスト</p> </body> </html> HTML; echo "--- 変換前のHTML文字列 ---\n"; echo $html_string_broken . "\n\n"; // 悪い例:エンコーディング指定なし echo "--- 悪い例:エンコーディング指定なしでloadHTML ---\n"; $dom_bad = new DOMDocument(); libxml_use_internal_errors(true); // エラーを抑制 $dom_bad->loadHTML($html_string_broken); $body_bad = $dom_bad->getElementsByTagName('body')->item(0); if ($body_bad) { echo "Body Content (Bad): " . $body_bad->textContent . "\n\n"; // 期待通りに表示されない可能性がある } // 良い例:XML宣言でUTF-8エンコーディングを明示的に指定 echo "--- 良い例:XML宣言でUTF-8を明示的に指定してloadHTML ---\n"; $dom_good = new DOMDocument(); libxml_use_internal_errors(true); // エラーを抑制 // HTMLの先頭にエンコーディング指定を追加 // DOMDocumentがHTMLとして正しく解釈できるよう、"<html>"タグの直前やドキュメントの最初に挿入する形式が安定します。 // HTML5のドキュメントではXML宣言は通常使われませんが、DOMDocumentの挙動を制御するための一般的なワークアラウンドです。 // HTML内の <meta charset="UTF-8"> が正しく設定されていれば、多くの場合はloadHTMLがそれを尊重します。 // それでも文字化けする場合、入力データが既に破損しているか、BOMなしUTF-8として正しく認識されていない可能性があります。 $html_with_encoding = '<?xml encoding="UTF-8">' . $html_string_broken; $dom_good->loadHTML($html_with_encoding); $body_good = $dom_good->getElementsByTagName('body')->item(0); if ($body_good) { echo "Body Content (Good): " . $body_good->textContent . "\n\n"; // 期待通りに「こんにちは、世界!」と表示される } libxml_use_internal_errors(false); // エラー抑制を解除 ?>

解説: '<?xml encoding="UTF-8">' . $html_string_broken のように、HTML文字列の先頭にXML宣言を追加することで、DOMDocumentはドキュメントをUTF-8として処理するようになります。これにより、日本語や特殊文字が正しくデコードされ、文字化けが解消されます。

課題2:saveHTML()におけるエンティティ化の制御

DOMDocument::saveHTML()メソッドは、DOMツリーをHTML文字列として出力しますが、この際のエンティティ化の挙動が意図と異なる場合があります。例えば、特定の文字を常にHTMLエンティティとして出力したい場合や、逆に不要なエンティティ化を避けたい場合があります。

原因

saveHTML()は、W3Cの標準に則り、HTMLの仕様上エンティティ化が必要な文字(<, >, &, " など)を自動的にエンティティ化します。しかし、それ以外の非ASCII文字(例: é, ©, 日本語)については、デフォルトではエンティティ化せず、そのままの文字として出力しようとします。これはUTF-8環境では問題ないことが多いですが、出力先のシステムやブラウザが異なるエンコーディングを想定している場合、文字化けの原因となりえます。

また、&nbsp;(ノーブレークスペース)のような特定のエンティティが、saveHTML()によって単なるスペース文字として出力されてしまうこともあります。

解決策:出力後の後処理またはDOM操作中の制御

saveHTML()自体に、出力される全ての非ASCII文字を数値エンティティ化するような直接的なオプションは提供されていません。そのため、以下のいずれかのアプローチを取ることになります。

  1. 出力されたHTML文字列を後処理する: saveHTML()で出力された文字列に対して、htmlspecialchars()htmlentities()を適用して、特定の文字を強制的にエンティティ化する。
  2. DOM操作中にエンティティを明示的に挿入する: DOMDocument::createTextNode()でテキストノードを追加する際に、あらかじめhtmlspecialchars()でエンティティ化した文字列を使用するか、DOMDocument::createEntityReference()を使う。
例1:saveHTML()後の後処理で非ASCII文字をエンティティ化

これは、出力されるHTML全体のエンティティ化レベルを調整したい場合に有効ですが、HTMLタグ自体もエンティティ化されてしまうリスクがあるため、慎重に適用する必要があります。

Php
<?php $dom = new DOMDocument(); libxml_use_internal_errors(true); $html_input = '<?xml encoding="UTF-8"><body><p>こんにちは、世界! &eacute; &copy; &amp;nbsp;</p></body>'; $dom->loadHTML($html_input); libxml_use_internal_errors(false); $node = $dom->getElementsByTagName('p')->item(0); if ($node) { // DOMDocumentのデフォルトのsaveHTML // 日本語はそのまま、&eacute;はé、&copy;は©、&amp;nbsp;は通常スペース(または&nbsp;が維持される場合もある) $original_output = $dom->saveHTML($node); echo "--- saveHTML()のデフォルト出力 ---\n"; echo htmlspecialchars($original_output, ENT_QUOTES | ENT_HTML5, 'UTF-8') . "\n\n"; // 上記のhtmlspecialcharsは表示のため。実際の出力は次のようになる。 // <p>こんにちは、世界! é © &nbsp;</p> (環境によっては &nbsp; もスペースになる) // 出力文字列をhtmlspecialcharsでさらにエンティティ化 // ただし、この方法だとHTMLタグ自体もエンティティ化されてしまうため、 // HTMLとしてレンダリングされなくなることに注意。 // そのため、これは「HTMLコードを表示する目的」などで利用されます。 // 実際にHTMLとして使いたい場合は、以下のテキストノード操作を参照。 $fully_encoded_output = htmlspecialchars($original_output, ENT_QUOTES | ENT_HTML5, 'UTF-8'); echo "--- htmlspecialcharsで完全にエンティティ化された出力 (HTMLタグも変換される) ---\n"; echo $fully_encoded_output . "\n\n"; // 結果例: &lt;p&gt;こんにちは、世界! &eacute; &copy; &amp;nbsp;&lt;/p&gt; } ?>

解説: htmlspecialchars()をHTML全体に適用すると、HTMLタグ自体もエンティティ化されてしまい、もはやHTMLとして機能しなくなります。この後処理は、HTMLの特定の箇所(例えば、ユーザーが入力したコンテンツをDOMDocumentで処理した後、再度HTMLに挿入するが、その際に安全性を高めるためにエンティティ化したい場合など)に限定して使うべきです。

より正確には、DOMDocumentで操作するテキストノードのを対象にhtmlspecialchars()を適用し、その結果をDOMにセットし直す、というアプローチが適切です。

例2:テキストノードの追加・更新時のエンティティ化

既存の要素に新しいテキストを追加する際や、既存のテキストノードを更新する際に、意図的に特定の文字をエンティティ化しておきたい場合があります。

Php
<?php $dom = new DOMDocument('1.0', 'UTF-8'); // doctypeを追加しないとsaveHTMLでHTML5形式にならないことがある $dom->loadHTML('<!DOCTYPE html><html><body></body></html>'); $body = $dom->getElementsByTagName('body')->item(0); $p1 = $dom->createElement('p'); // 通常のテキストノードの追加 (DOMDocumentが自動でエンティティ化する部分のみ) $p1->appendChild($dom->createTextNode('Hello <World> &amp; 日本語')); $body->appendChild($p1); echo "--- createTextNode()のデフォルト出力 ---\n"; echo $dom->saveHTML() . "\n\n"; // 結果例: <p>Hello &lt;World&gt; &amp; 日本語</p> // '<' と '&' は自動でエンティティ化されるが、日本語はそのまま。 // 新しい要素とテキストを追加し、特定の文字を強制的にエンティティ化したい場合 $p2 = $dom->createElement('p'); $special_text = '特殊文字 é © ™'; // ここでは特殊文字をテキストとして追加。saveHTMLはUTF-8ならそのまま出力する。 $p2->appendChild($dom->createTextNode($special_text)); $body->appendChild($p2); echo "--- 特殊文字のデフォルト出力 (非ASCII文字はそのまま) ---\n"; echo $dom->saveHTML() . "\n\n"; // 結果例: <p>特殊文字 é © ™</p> // もし、これらの特殊文字を常に数値エンティティとして出力したい場合、 // 通常はDOM操作後に文字列置換を行うか、テキストノードを生成する前に // mb_encode_numericentity()のような関数で加工する必要があります。 // しかし、現代のWebではUTF-8で直接文字を出力する方が一般的であり、 // 特殊な要件がない限りは不要です。 ?>

解説: DOMDocument::createTextNode()は、引数で渡された文字列をプレーンテキストとして扱い、HTMLの特殊文字(<, >, &, " など)のみを自動的にエンティティ化します。その他の非ASCII文字(日本語やéなど)は、内部的にUTF-8文字として保持され、saveHTML()時にそのまま出力されます。

もし、すべての非ASCII文字を数値エンティティ化したい場合は、saveHTML()の後にmb_encode_numericentity()のような関数で後処理を行うか、ノードを追加する前に文字列自体を加工する必要があります。しかし、これはDOMDocumentの通常の利用方法から外れるため、多くの場合、必要ありません。現代のウェブ環境では、UTF-8で直接文字を出力する方が一般的です。

解決策3:createEntityReference()による明示的なエンティティ参照の挿入

特定のHTMLエンティティ(例: &nbsp;, &mdash;)を、デコードされた文字としてではなく、エンティティ参照そのものとしてDOMツリーに挿入したい場合があります。DOMDocument::createEntityReference()メソッドは、この目的のために使用されます。

Php
<?php $dom = new DOMDocument('1.0', 'UTF-8'); // doctypeを追加しないとsaveHTMLでHTML5形式にならないことがある $dom->loadHTML('<!DOCTYPE html><html><body></body></html>'); $body = $dom->getElementsByTagName('body')->item(0); $p = $dom->createElement('p'); // 通常のスペース $p->appendChild($dom->createTextNode('Normal space here: ')); // &nbsp; エンティティ参照を明示的に挿入 $p->appendChild($dom->createEntityReference('nbsp')); $p->appendChild($dom->createTextNode(' and also here: ')); $p->appendChild($dom->createEntityReference('nbsp')); $p->appendChild($dom->createEntityReference('nbsp')); $body->appendChild($p); // DOMDocumentのエンコーディング設定 $dom->encoding = 'UTF-8'; // saveHTMLの出力に影響を与える可能性がある echo "--- createEntityReference() を使用した出力 ---\n"; echo $dom->saveHTML() . "\n\n"; // 結果の例: <p>Normal space here:&nbsp; and also here:&nbsp;&nbsp;</p> // (実際の出力はDOMDocumentのバージョンや設定に依存する場合がありますが、 // &nbsp; がそのまま出力されることが期待されます) ?>

解説: createEntityReference('nbsp') を使用することで、&nbsp; というエンティティ参照がDOMツリーにノードとして追加され、saveHTML()時にそのエンティティ参照がそのまま出力されます。これは、特定のエンティティを視覚的な空白としてではなく、セマンティックなエンティティとして保持したい場合に特に有効です。

ハマった点やエラー解決

1. loadHTML()で警告が出まくる、またはパースに失敗する

問題: DOMDocument::loadHTML()は、HTMLの構文エラーや不完全なHTMLに対しても厳密で、大量の警告(PHP Warning: DOMDocument::loadHTML():)を出力したり、最悪の場合、DOMツリーが正常に構築されないことがあります。

解決策: libxml_use_internal_errors(true);loadHTML() の呼び出し前に設定することで、DOMDocument内部で発生するlibxmlのエラーや警告を抑制し、DOMDocumentオブジェクトが正常に生成されるようにします。処理が完了したら libxml_use_internal_errors(false); で元に戻すのが良い習慣です。これにより、開発中のデバッグ中に本当に重要なエラーだけを把握しやすくなります。

Php
<?php $dom = new DOMDocument(); libxml_use_internal_errors(true); // エラー抑制開始 $dom->loadHTML('<p>不完全なHTML</div>'); // 閉じタグが合わないなどのエラーが発生しうる // 何らかの処理 libxml_use_internal_errors(false); // エラー抑制解除 ?>

2. &amp;&lt; などの文字エンティティがデコードされてしまう

問題: 読み込んだHTMLに含まれていた &amp;& に、&lt;< に、&gt;> に、&quot;" に、&apos;' にデコードされてしまう。これを元のエンティティ参照として保持したい。

解決策: これはDOMDocumentの基本的な挙動であり、HTMLエンティティが指す文字自体をDOMツリーの内部表現とするためです。通常、この挙動は正しく、HTMLを操作する上で問題にはなりません。もし、どうしてもこれらの文字をエンティティ参照として出力したい場合は、saveHTML()で出力された後に、文字列置換を行うか、上記で説明したcreateEntityReference()を適切に使う必要があります。

ただし、createEntityReference('amp'); のようにして &amp; を挿入しても、saveHTML() の過程で & にデコードされることがよくあります。これは saveHTML() がHTMLの仕様に準拠しようとするためです。HTMLの仕様では、&&amp; にエスケープされなければならない文字だからです。

この挙動を回避し、プレーンな &&amp; という文字列として(つまり二重エンティティ化された &amp;amp; のように)保持したい場合は、DOMノードを作成する前に htmlspecialchars() を二重に適用するなどの工夫が必要になりますが、これは非常に稀なケースであり、通常は推奨されません。

最も一般的なアプローチは、テキストノードのコンテンツを取得した後、htmlspecialchars() を使って必要な部分のみを再度エンティティ化し、それをDOMに再挿入するか、最終出力で適用することです。

3. &nbsp; が普通のスペースとして扱われる

問題: &nbsp;(ノーブレークスペース)をDOMDocumentで処理すると、通常の半角スペースとしてデコードされてしまうことがあります。これにより、HTMLのレイアウトが意図せず崩れる可能性があります。

解決策: 上記「解決策3:createEntityReference()による明示的なエンティティ参照の挿入」で示したように、$dom->createEntityReference('nbsp') を使って明示的に &nbsp; エンティティ参照をDOMツリーに挿入することで、saveHTML()時にそれが &nbsp; として出力されるようになります。

Php
<?php $dom = new DOMDocument(); // doctypeを追加しないとsaveHTMLでHTML5形式にならないことがある $dom->loadHTML('<!DOCTYPE html><body><p>文章と&nbsp;文章の間にスペース</p></body>'); $p = $dom->getElementsByTagName('p')->item(0); // テキストコンテンツを取得すると、&nbsp;は通常のスペースにデコードされている echo "textContent: " . $p->textContent . "\n"; // "文章と 文章の間にスペース" // saveHTML() で出力すると &nbsp; が保持されることもあるが、環境依存の可能性 echo "saveHTML (original): " . $dom->saveHTML($p) . "\n"; // 明示的に &nbsp; を挿入し直す例 $new_dom = new DOMDocument('1.0', 'UTF-8'); $new_dom->loadHTML('<!DOCTYPE html><body></body></html>'); // 基本構造をロード $new_body = $new_dom->getElementsByTagName('body')->item(0); $new_p = $new_dom->createElement('p'); $new_p->appendChild($new_dom->createTextNode('文章と')); $new_p->appendChild($new_dom->createEntityReference('nbsp')); // &nbsp; を明示的に挿入 $new_p->appendChild($new_dom->createTextNode('文章の間にスペース')); $new_body->appendChild($new_p); echo "saveHTML (with createEntityReference): " . $new_dom->saveHTML() . "\n"; ?>

DOMDocumentはHTMLを構造的に解釈するため、エンティティの扱いにはある程度の「おまかせ」が発生します。開発者はその挙動を理解し、必要に応じてlibxml_use_internal_errors<?xml encoding="...">htmlspecialcharscreateEntityReferenceなどを組み合わせて、意図通りのHTMLパースと生成を実現することが重要です。

まとめ

本記事では、PHPの強力なHTML/XMLパーサーであるDOMDocumentを使用する際に、HTMLエンティティの扱いがなぜ重要であるか、そしてそれに伴う具体的な課題とその解決策を詳しく解説しました。

  • DOMDocumentのエンティティ処理の理解: loadHTML()がエンティティをデコードし、saveHTML()が必要に応じて再エンティティ化するという基本的な挙動を把握することが出発点です。
  • 文字化け対策: loadHTML()時に<?xml encoding="UTF-8">のようなXML宣言をHTML文字列の先頭に付与することで、エンコーディングの誤認識による文字化けを効果的に防ぐことができます。また、libxml_use_internal_errors(true)でパースエラーを抑制することが安定した動作に繋がります。
  • 出力時のエンティティ制御: saveHTML()だけでは全てのエンティティ化を柔軟に制御できないため、非ASCII文字を強制的にエンティティ化したい場合は、htmlspecialchars()を使った出力後の後処理や、DOMDocument::createEntityReference()による明示的なエンティティ参照の挿入を検討します。

この記事を通して、PHP DOMDocumentを使ったHTMLのパースと生成において、エンティティ処理に関する潜在的な問題を未然に防ぎ、より堅牢で意図通りの結果を得るための知識と実践的なスキルを習得できたことと思います。今後は、WebスクレイピングのデータクレンジングやHTMLコンテンツの動的な生成など、様々な場面でDOMDocumentを自信を持って活用できるでしょう。

参考資料