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

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

このコミットは、Go言語の標準ライブラリhtmlパッケージにおけるHTMLパーサーの挙動を修正するものです。具体的には、<a>タグが暗黙的に終了タグを生成する際のロジックを改善し、特定のHTML構造(テーブル内の<a>タグ)において、誤った要素のクローズを防ぐことを目的としています。

変更されたファイルは以下の通りです。

  • src/pkg/html/node.go: HTMLノードのスタック操作に関連するファイル。コメントの追加が行われています。
  • src/pkg/html/parse.go: HTMLパーサーの主要なロジックが記述されているファイル。<a>タグの処理に関する重要な変更が含まれています。
  • src/pkg/html/parse_test.go: パーサーのテストケースが記述されているファイル。新しいテストケースの追加(または既存テストの範囲拡張)が行われています。

コミット

このコミットは、HTMLパーサーが<a>タグの暗黙的な終了タグを生成する際に、スコープマーカーノードで検索を停止するように変更します。これにより、テーブルセル内に存在する<a>タグが、テーブル外の開いている<a>要素を誤ってクローズする問題を解決します。

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

https://github.com/golang/go/commit/22ee5ae25a2997606c28abe721c9052ee0cc9da4

元コミット内容

html: stop at scope marker node when generating implied </a> tags

A <a> tag generates implied end tags for any open <a> elements.
But it shouldn't do that when it is inside a table cell the the open <a>
is outside the table.
So stop the search for an open <a> when we reach a scope marker node.

Pass tests1.dat, test 78:
<a href="blah">aba<table><tr><td><a href="foo">br</td></tr>x</table>aoe

| <html>
|   <head>
|   <body>
|     <a>
|       href="blah"
|       "abax"
|       <table>
|         <tbody>
|           <tr>
|             <td>
|               <a>
|                 href="foo"
|                 "br"
|       "aoe"

Also pass test 79:
<table><a href="blah">aba<tr><td><a href="foo">br</td></tr>x</table>aoe

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

変更の背景

HTMLのパースは非常に複雑であり、特にHTML5のパースアルゴリズムは、ブラウザ間の互換性を保つために厳密に定義されています。このコミットは、HTML5のパースアルゴリズムにおける特定のルール、特に「アクティブなフォーマット要素のスタック (stack of active formatting elements)」と「要素のスタック (stack of open elements)」の挙動に関連するバグを修正しています。

問題は、<a>タグが入れ子になった場合に発生します。HTML5の仕様では、新しい<a>タグが開始されると、アクティブなフォーマット要素のスタック内にある既存の<a>要素は、暗黙的に終了される(つまり、その終了タグが挿入される)必要があります。しかし、このルールには例外があります。特定の「スコープマーカーノード」を越えて検索してはならないというものです。

コミットメッセージに示されている例では、以下のようなHTML構造が問題を引き起こしていました。

<a href="blah">aba<table><tr><td><a href="foo">br</td></tr>x</table>aoe

このHTMLでは、最初の<a>タグが開始され、その中に<table>がネストされています。そして、<table>の内部(<td>内)で2つ目の<a>タグが開始されています。

従来のパーサーの挙動では、2つ目の<a>タグが開始された際に、アクティブなフォーマット要素のスタックを遡って最初の<a>タグを見つけ、それを暗黙的に終了させてしまっていました。しかし、HTML5の仕様では、<table>要素は「スコープマーカーノード」として機能し、テーブル内部からテーブル外部の要素を暗黙的にクローズするのを防ぐ必要があります。このコミットは、この仕様に準拠するようにパーサーのロジックを修正しています。

前提知識の解説

このコミットを理解するためには、以下のHTML5パースアルゴリズムに関する前提知識が必要です。

  1. HTML5パースアルゴリズム: HTML5の仕様は、ウェブブラウザがHTMLドキュメントをどのように解析し、DOMツリーを構築するかを厳密に定義しています。これは、ブラウザ間の互換性を保証するために非常に重要です。パースは、トークン化とツリー構築の2つのフェーズに分かれています。

  2. 要素のスタック (Stack of Open Elements): これは、現在開いているHTML要素(開始タグが処理されたが、まだ対応する終了タグが処理されていない要素)を追跡するためのスタック構造です。新しい要素が開始されるとスタックにプッシュされ、終了タグが処理されるとポップされます。DOMツリーの階層構造を反映しています。

  3. アクティブなフォーマット要素のスタック (Stack of Active Formatting Elements - AFE): これは、特定のフォーマット要素(例: <a>, <b>, <i>, <strong>など)が、DOMツリーのどこに挿入されるかに関わらず、現在「アクティブ」である状態を追跡するためのスタックです。これらの要素は、DOMツリーの構造とは独立して、テキストのフォーマットに影響を与える可能性があります。例えば、<a>タグはリンクの範囲を定義します。

  4. 暗黙的な終了タグの生成 (Implied End Tags): HTMLでは、一部の要素は特定の状況下で、対応する終了タグがなくても自動的に閉じられる(暗黙的に終了タグが挿入される)ことがあります。例えば、<li>要素の後に別の<li>要素が続く場合、前の<li>は自動的に閉じられます。<a>タグもこの挙動を持ち、新しい<a>タグが開始されると、アクティブなフォーマット要素のスタックにある既存の<a>タグが暗黙的に閉じられることがあります。

  5. スコープマーカーノード (Scope Marker Nodes): HTML5パースアルゴリズムにおいて、特定の要素は「スコープマーカーノード」として定義されています。これらは、アクティブなフォーマット要素のスタックを検索する際に、それ以上遡ってはいけない境界として機能します。例えば、<table>, <tbody>, <thead>, <tfoot>, <tr>などのテーブル関連要素や、<html>, <body>などはスコープマーカーノードです。これは、テーブルの構造が、その内部で開始された要素がテーブル外部の要素に影響を与えることを防ぐために重要です。

    このコミットの文脈では、<table>がスコープマーカーノードとして機能し、テーブル内部で開始された<a>タグが、テーブル外部で開いている<a>タグを暗黙的にクローズするのを防ぐ役割を果たします。

技術的詳細

このコミットの核心は、HTML5パースアルゴリズムの「アクティブなフォーマット要素のスタック」の処理における<a>タグの特殊な挙動の修正です。

HTML5の仕様では、<a>タグが開始される際の処理の一部として、以下のステップが含まれます(簡略化)。

  1. 新しい<a>トークンが受信された場合。
  2. アクティブなフォーマット要素のスタックを、最も最近追加された要素から順に遡って検索します。
  3. この検索は、以下のいずれかの条件が満たされるまで続きます。
    • スタックの先頭に到達した。
    • 検索中の要素が、特定の「スコープマーカーノード」である。
    • 検索中の要素が、<a>要素である。
  4. もし検索中に<a>要素が見つかった場合、その<a>要素はアクティブなフォーマット要素のスタックと要素のスタックの両方から削除され、暗黙的に終了タグが挿入されたかのように扱われます。

このコミット以前のGoのhtmlパーサーでは、この検索ロジックがスコープマーカーノードを適切に考慮していませんでした。そのため、テーブルのようなスコープマーカーノードの内部で新しい<a>タグが開始された場合でも、パーサーはスタックを遡り続け、テーブル外部で開いている<a>タグを誤ってクローズしてしまっていました。

修正後のロジックでは、アクティブなフォーマット要素のスタックを遡る際に、現在の要素がscopeMarkerNodeであるかどうかをチェックします。もしscopeMarkerNodeに到達した場合、それ以上検索を続行せずにループをbreakします。これにより、テーブル内部の<a>タグがテーブル外部の<a>タグに影響を与えることがなくなり、HTML5の仕様に準拠した正しいパース結果が得られるようになります。

コミットメッセージの例で示されているDOMツリーの期待される結果は、この修正によって達成されます。最初の<a>タグはテーブルの外部で開いたままになり、テーブル内部の2つ目の<a>タグは独立してパースされます。

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

このコミットの主要な変更は、src/pkg/html/parse.goファイルのinBodyIM関数内のcase "a":ブロックにあります。

--- a/src/pkg/html/parse.go
+++ b/src/pkg/html/parse.go
@@ -553,10 +553,13 @@ func inBodyIM(p *parser) (insertionMode, bool) {
 			}
 			p.addElement(p.tok.Data, p.tok.Attr)
 		case "a":
-			if n := p.afe.forTag("a"); n != nil {
-				p.inBodyEndTagFormatting("a")
-				p.oe.remove(n)
-				p.afe.remove(n)
+			for i := len(p.afe) - 1; i >= 0 && p.afe[i].Type != scopeMarkerNode; i-- {
+				if n := p.afe[i]; n.Type == ElementNode && n.Data == "a" {
+					p.inBodyEndTagFormatting("a")
+					p.oe.remove(n)
+					p.afe.remove(n)
+					break
+				}
 			}
 			p.reconstructActiveFormattingElements()
 			p.addFormattingElement(p.tok.Data, p.tok.Attr)

また、src/pkg/html/node.goにはコメントが追加されていますが、これは直接的なロジック変更ではありません。

--- a/src/pkg/html/node.go
+++ b/src/pkg/html/node.go
@@ -135,6 +135,8 @@ func (s *nodeStack) remove(n *Node) {
 	*s = (*s)[:j]
 }
 
+// TODO(nigeltao): forTag no longer used. Should it be deleted?
+
 // forTag returns the top-most element node with the given tag.
 func (s *nodeStack) forTag(tag string) *Node {
 	for i := len(*s) - 1; i >= 0; i-- {

そして、テストファイル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 < 78; i++ {
+		for i := 0; i < 80; i++ {
 			// Parse the #data section.
 			b, err := ioutil.ReadAll(<-rc)
 			if err != nil {

コアとなるコードの解説

src/pkg/html/parse.goの変更箇所に焦点を当てて解説します。

変更前:

			if n := p.afe.forTag("a"); n != nil {
				p.inBodyEndTagFormatting("a")
				p.oe.remove(n)
				p.afe.remove(n)
			}

このコードは、p.afe.forTag("a")を呼び出して、アクティブなフォーマット要素のスタック(p.afe)から最も最近の<a>タグを検索していました。forTagメソッドは、スタックの先頭から順に要素を検索し、指定されたタグ名(この場合は"a")を持つ最初の要素を返します。この実装では、スコープマーカーノードの概念が考慮されていませんでした。

変更後:

			for i := len(p.afe) - 1; i >= 0 && p.afe[i].Type != scopeMarkerNode; i-- {
				if n := p.afe[i]; n.Type == ElementNode && n.Data == "a" {
					p.inBodyEndTagFormatting("a")
					p.oe.remove(n)
					p.afe.remove(n)
					break
				}
			}

この新しいコードブロックは、以下の点で改善されています。

  1. 明示的なループとスコープマーカーノードのチェック: for i := len(p.afe) - 1; i >= 0 && p.afe[i].Type != scopeMarkerNode; i-- { ... } このループは、アクティブなフォーマット要素のスタックp.afeを逆順(最も最近追加された要素から)に走査します。重要なのは、ループの条件にp.afe[i].Type != scopeMarkerNodeが追加されたことです。これにより、現在の要素がscopeMarkerNodeタイプである場合、ループはそれ以上続行されず、breakします。これは、HTML5の仕様で定義されている「スコープマーカーノードを越えて検索しない」というルールを直接実装しています。

  2. 要素タイプの確認: if n := p.afe[i]; n.Type == ElementNode && n.Data == "a" { ... } ループ内で、現在の要素nElementNodeタイプであり、かつそのデータ(タグ名)が"a"であるかを厳密にチェックします。

  3. breakステートメントの追加: break <a>要素が見つかり、それが適切に処理された後、ループはbreakされます。これは、HTML5の仕様で「最も最近の<a>要素のみを処理する」という要件に合致しています。

この変更により、パーサーはテーブルのようなスコープマーカーノードの内部で開始された<a>タグが、テーブル外部の<a>タグに影響を与えることなく、正しくパースされるようになります。

src/pkg/html/node.goに追加されたコメント// TODO(nigeltao): forTag no longer used. Should it be deleted?は、forTagメソッドがこのコミットの変更によって使用されなくなったことを示唆しており、将来的なコードクリーンアップの可能性を示しています。

src/pkg/html/parse_test.goのテスト範囲の拡張は、この修正が既存のテストケースを通過し、さらに新しいテストケース(おそらくコミットメッセージで言及されているテスト78と79)もカバーしていることを確認するために行われました。

関連リンク

参考にした情報源リンク

これらのリンクは、HTML5パースアルゴリズムの公式仕様であり、このコミットが修正しようとしている問題の背景にある詳細なルールを理解するのに役立ちます。特に、<a>タグの処理、アクティブなフォーマット要素のスタック、およびスコープマーカーノードに関するセクションが関連します。