Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 10123] ファイルの概要

このコミットは、Go言語のhtmlパッケージにおけるHTMLパーサーの挙動を修正するものです。具体的には、<option>要素が閉じられるべきタイミングで<optgroup>要素が開始された場合に、正しく<option>要素を閉じるようにパーサーのロジックが改善されています。これにより、不正なHTML構造に対するパーサーの堅牢性が向上し、ブラウザの挙動により近づけることを目的としています。

コミット

  • Author: Andrew Balholm andybalholm@gmail.com
  • Date: Thu Oct 27 09:45:53 2011 +1100
  • Commit Hash: bd07e4f25906f4443811e3b6bdb4ff2918beed0c

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/bd07e4f25906f4443811e3b6bdb4ff2918beed0c

元コミット内容

html: close <option> element when opening <optgroup>

Pass tests1.dat, test 34:
<!DOCTYPE html>A<option>B<optgroup>C<select>D</option>E

| <!DOCTYPE html>
| <html>
|   <head>
|   <body>
|     "A"
|     <option>
|       "B"
|     <optgroup>
|       "C"
|       <select>
|         "DE"

Also passes tests 35-48. Test 48 is:
</ COM--MENT >

R=nigeltao
CC=golang-dev
https://golang.org/cl/5311063

変更の背景

HTMLのパースは、厳密なXMLのような構造とは異なり、ブラウザがエラー耐性を持つように設計されています。これは、ウェブ上に存在する多くのHTMLが完全にW3Cの仕様に準拠していないためです。ブラウザは、不正なHTMLに対しても可能な限りレンダリングを試みるため、パーサーは特定の状況下で要素を自動的に閉じたり、欠落しているタグを補完したりする「エラー回復」メカニズムを持っています。

このコミットの背景にあるのは、<option>要素と<optgroup>要素の間の特定の相互作用です。HTMLの仕様では、<option>要素は<select>または<optgroup>の子要素としてのみ配置されるべきであり、<option>要素の内部に直接<optgroup>要素をネストすることは許可されていません。しかし、ユーザーが誤って<!DOCTYPE html>A<option>B<optgroup>C<select>D</option>EのようなHTMLを記述した場合、ブラウザはこれをどのように解釈し、DOMツリーを構築するかが問題となります。

元のGoのHTMLパーサーは、このようなケースで<option>要素を適切に閉じずに<optgroup>要素を処理してしまい、結果としてブラウザの挙動と異なるDOMツリーを生成していました。これは、GoのhtmlパッケージがウェブブラウザのHTMLパース挙動を正確にエミュレートすることを目指しているため、修正が必要なバグと認識されました。

コミットメッセージに記載されているtests1.dat, test 34は、HTML5のパース仕様に準拠したテストスイートの一部であり、特定の不正なHTMLスニペットがどのようにパースされるべきかを示しています。このテストケースをパスすることが、ブラウザ互換性向上のための重要な目標でした。

前提知識の解説

HTMLパーシングとDOM

HTMLパーシングとは、HTMLドキュメントを読み込み、その構造を解析して、ブラウザが理解できる内部表現(通常はDOMツリー)に変換するプロセスです。DOM(Document Object Model)は、HTMLやXMLドキュメントの論理構造をツリー形式で表現するAPIです。各HTML要素、属性、テキストノードはDOMツリーのノードとして表現されます。

<option>要素と<optgroup>要素

  • <option>要素: <select>要素内でドロップダウンリストの個々の選択肢を定義します。
  • <optgroup>要素: <select>要素内で関連する<option>要素をグループ化するために使用されます。これにより、ドロップダウンリスト内で選択肢をカテゴリ別に整理できます。

これらの要素は、特定の親子関係を持つことがHTML仕様で定められています。特に、<option>要素は<optgroup>の直接の子要素になることはできますが、<optgroup><option>の直接の子要素になることはできません。

HTMLパーサーのエラー回復

HTMLパーサーは、不正なマークアップ(閉じタグの欠落、不正なネストなど)に遭遇した場合でも、エラーを報告して停止するのではなく、可能な限りDOMツリーを構築しようとします。このプロセスを「エラー回復」と呼びます。HTML5の仕様には、このようなエラー回復の具体的なルールが詳細に定義されており、ブラウザ間の互換性を保証するために重要です。

例えば、多くのブラウザは、<p>タグの内部に別のブロックレベル要素(例: <div>)が出現した場合、自動的に<p>タグを閉じます。今回のケースもこれに似ており、<option>要素の内部に<optgroup>要素が出現した場合、パーサーは<option>要素を自動的に閉じるべきであるというルールに基づいています。

挿入モード (Insertion Mode)

HTML5のパースアルゴリズムでは、「挿入モード」という概念が非常に重要です。これは、パーサーが現在どの状態にあるかを示し、次にどのトークン(タグ、テキストなど)をどのように処理するかを決定します。例えば、inBodyIMは「body要素内での挿入モード」を指し、HTMLドキュメントの<body>タグのコンテンツをパースしている状態です。各挿入モードには、特定のタグが検出された場合の詳細な処理ルールが定義されています。

技術的詳細

このコミットは、Go言語のhtmlパッケージ内のHTMLパーサーの主要な部分であるinBodyIM関数に修正を加えています。inBodyIMは、HTMLドキュメントの<body>要素のコンテンツをパースする際の挿入モードを処理する関数です。

HTMLパーサーは、入力ストリームからトークン(開始タグ、終了タグ、テキストなど)を読み込み、それらのトークンに基づいてDOMツリーを構築します。このプロセスでは、要素スタック(現在開いている要素のリスト)とアクティブフォーマット要素リスト(特定のフォーマット要素を追跡するためのリスト)が重要な役割を果たします。

変更の核心は、inBodyIM関数内で<optgroup>または<option>タグが検出された際の処理ロジックにあります。

  • p.top().Data == "option": これは、現在要素スタックの最上位(つまり、現在開いている最も内側の要素)が<option>要素であるかどうかをチェックしています。
  • p.oe.pop(): もし最上位要素が<option>であり、かつ次に<optgroup>または別の<option>タグが検出された場合、これは現在の<option>要素が暗黙的に閉じられるべき状況であることを示します。p.oe.pop()は、要素スタックから最上位の要素(この場合は<option>)を削除し、その要素を閉じます。
  • p.reconstructActiveFormattingElements(): この関数は、アクティブフォーマット要素リストを再構築します。これは、HTMLパーシングにおいて、特定のフォーマット要素(例: <b>, <i>)が正しくネストされていない場合に、それらを適切に処理するために必要となるステップです。要素が閉じられたり開かれたりする際に、このリストを最新の状態に保つことで、DOMツリーの整合性を維持します。
  • p.addElement(p.tok.Data, p.tok.Attr): 最後に、検出された新しいタグ(この場合は<optgroup>または<option>)をDOMツリーに追加し、要素スタックにプッシュします。

この修正により、パーサーはHTML5のパース仕様に準拠し、ブラウザが不正な<option><optgroup>のネストをどのように処理するかを正確に模倣できるようになります。

コアとなるコードの変更箇所

src/pkg/html/parse.go

--- a/src/pkg/html/parse.go
+++ b/src/pkg/html/parse.go
@@ -594,6 +594,12 @@ func inBodyIM(p *parser) (insertionMode, bool) {
 			}\n \t\t\tp.popUntil(buttonScopeStopTags, "p")
 			p.addElement("li", p.tok.Attr)
 		case "optgroup", "option":
 			if p.top().Data == "option" {
 				p.oe.pop()
 			}
 			p.reconstructActiveFormattingElements()
 			p.addElement(p.tok.Data, p.tok.Attr)
 		default:
 			// TODO.
 			p.addElement(p.tok.Data, p.tok.Attr)

src/pkg/html/parse_test.go

--- a/src/pkg/html/parse_test.go
+++ b/src/pkg/html/parse_test.go
@@ -132,7 +132,7 @@ func TestParser(t *testing.T) {
 		rc := make(chan io.Reader)
 		go readDat(filename, rc)
 		// TODO(nigeltao): Process all test cases, not just a subset.
-		for i := 0; i < 34; i++ {
+		for i := 0; i < 49; i++ {
 			// Parse the #data section.
 			b, err := ioutil.ReadAll(<-rc)
 			if err != nil {

コアとなるコードの解説

src/pkg/html/parse.go の変更

追加されたコードブロックは、inBodyIM関数内でトークンが<optgroup>または<option>である場合に実行されます。

		case "optgroup", "option":
			if p.top().Data == "option" {
				p.oe.pop()
			}
			p.reconstructActiveFormattingElements()
			p.addElement(p.tok.Data, p.tok.Attr)
  1. case "optgroup", "option":: これは、現在の入力トークンが<optgroup>または<option>の開始タグであることを示します。
  2. if p.top().Data == "option" { p.oe.pop() }:
    • p.top(): 現在開いている要素スタックの最上位の要素(最も内側の要素)を取得します。
    • p.top().Data == "option": その要素が<option>タグであるかどうかをチェックします。
    • p.oe.pop(): もし最上位の要素が<option>であれば、その<option>要素を要素スタックからポップ(削除)します。これは、HTML5のパースルールにおいて、<option>要素の内部に<optgroup>または別の<option>が出現した場合、現在の<option>要素が暗黙的に閉じられるべきであるという挙動を実装しています。これにより、不正なネストが修正され、DOMツリーがブラウザの期待する形に近づきます。
  3. p.reconstructActiveFormattingElements(): この呼び出しは、要素が閉じられたり開かれたりする際に、アクティブフォーマット要素リストの整合性を保つために重要です。これにより、<b><i>などのフォーマット要素が正しく適用されることが保証されます。
  4. p.addElement(p.tok.Data, p.tok.Attr): 最後に、現在処理中のトークン(<optgroup>または<option>)をDOMツリーに追加し、要素スタックにプッシュします。これにより、新しい要素がDOMツリーに正しく組み込まれます。

この変更により、<!DOCTYPE html>A<option>B<optgroup>C<select>D</option>Eのような入力に対して、パーサーは以下のようなDOM構造を生成するようになります。

<!DOCTYPE html>
<html>
  <head>
  <body>
    "A"
    <option>
      "B"
    </option> <!-- ここでoptionが閉じられる -->
    <optgroup>
      "C"
      <select>
        "DE"
      </select>
    </optgroup>
  </body>
</html>

これは、元のコミットメッセージに示されている期待される出力と一致します。

src/pkg/html/parse_test.go の変更

--- a/src/pkg/html/parse_test.go
+++ b/src/pkg/html/parse_test.go
@@ -132,7 +132,7 @@ func TestParser(t *testing.T) {
 		rc := make(chan io.Reader)
 		go readDat(filename, rc)
 		// TODO(nigeltao): Process all test cases, not just a subset.
-		for i := 0; i < 34; i++ {
+		for i := 0; i < 49; i++ {
 			// Parse the #data section.
 			b, err := ioutil.ReadAll(<-rc)
 			if err != nil {\n

この変更は、テストスイートの実行範囲を拡大しています。

  • for i := 0; i < 34; i++ から for i := 0; i < 49; i++: これは、tests1.datファイル内のテストケースを、以前の34個から49個まで実行するように変更しています。これにより、今回の修正が影響するテスト34だけでなく、テスト35から48までの他の関連するテストケースもカバーされるようになります。コミットメッセージに「Also passes tests 35-48. Test 48 is: </ COM--MENT >」とあるように、この変更によってより広範なテストが実行され、修正の正当性が確認されています。

関連リンク

参考にした情報源リンク