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

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

コミット

commit 632a2c59b12b525edac2fffa4ddd57b3de068707
Author: Andrew Balholm <andybalholm@gmail.com>
Date:   Fri Nov 4 15:48:11 2011 +1100

    html: properly close <tr> element when an new <tr> starts.
    
    Pass tests1.dat, test 87:
    <table><tr><tr><td><td><span><th><span>X</table>
    
    | <html>
    |   <head>
    |   <body>
    |     <table>
    |       <tbody>
    |         <tr>
    |         <tr>
    |           <td>
    |           <td>
    |             <span>
    |           <th>
    |             <span>
    |               "X"
    
    R=nigeltao
    CC=golang-dev
    https://golang.org/cl/5343041

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

https://github.com/golang/go/commit/632a2c59b12b525edac2fffa4ddd57b3de068707

元コミット内容

html: properly close <tr> element when an new <tr> starts.

Pass tests1.dat, test 87:
<table><tr><tr><td><td><span><th><span>X</table>

| <html>
|   <head>
|   <body>
|     <table>
|       <tbody>
|         <tr>
|         <tr>
|           <td>
|           <td>
|             <span>
|           <th>
|             <span>
|               "X"

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

変更の背景

このコミットは、Go言語のhtmlパッケージにおけるHTMLパーサーのバグ修正を目的としています。具体的には、HTMLのテーブル構造内で、<tr>(テーブル行)要素が適切に閉じられない場合に、新しい<tr>要素が開始されたときに既存の<tr>要素を正しく処理(閉じる)できない問題に対処しています。

HTMLの仕様では、特定の要素(例えば<tr>)は、その親要素(<tbody>など)の内部にのみ存在し、また、新しい同種の要素が開始された場合や、親要素が閉じられた場合などに暗黙的に閉じられることがあります。このコミットで修正された問題は、<table><tr><tr>...</table>のような、ネストされた<tr>タグが意図せず連続して出現するような不正なHTML構造をパースする際に発生していました。

元のパーサーは、最初の<tr>がまだ開いている状態で2番目の<tr>タグを検出したときに、最初の<tr>を適切に閉じずに、DOMツリーが不正な状態になる可能性がありました。これにより、ブラウザがレンダリングするような正しいDOM構造を生成できず、結果としてHTMLの解釈に不整合が生じていました。

コミットメッセージに記載されているテストケース <table><tr><tr><td><td><span><th><span>X</table> は、この問題の典型的な例です。このHTMLスニペットは、<table>内に2つの連続した<tr>タグを含んでいます。正しいHTMLパースでは、2番目の<tr>タグが検出された時点で最初の<tr>タグは暗黙的に閉じられるべきです。この修正は、この挙動をGoのHTMLパーサーに実装することで、より堅牢で仕様に準拠したパース結果を提供します。

前提知識の解説

HTMLパースとDOMツリー

HTMLパースとは、HTMLドキュメントのテキストデータを読み込み、それをブラウザが理解できる構造化されたデータ(DOMツリー)に変換するプロセスです。DOM(Document Object Model)ツリーは、HTMLドキュメントの論理的な構造をツリー形式で表現したもので、各HTML要素はツリーのノードとして表現されます。

HTMLの要素と構造

  • <table>: テーブル全体を定義します。
  • <tbody>: テーブルの本体部分を定義します。通常、<table>の直下に暗黙的に生成されるか、明示的に記述されます。
  • <tr>: テーブルの行(Table Row)を定義します。
  • <td>: テーブルのデータセル(Table Data)を定義します。<tr>の子要素として配置されます。
  • <th>: テーブルのヘッダーセル(Table Header)を定義します。<tr>の子要素として配置されます。

HTML5パースアルゴリズムと挿入モード

現代のWebブラウザは、HTML5のパースアルゴリズムに従ってHTMLを解析します。このアルゴリズムは、不正なHTMLに対しても堅牢であり、エラーを許容しながらDOMツリーを構築します。その中心的な概念の一つが「挿入モード(Insertion Mode)」です。

挿入モードは、パーサーが現在どのHTML要素のコンテキストでトークンを処理しているかを示す状態です。例えば、<table>要素の内部では「in table」モード、<tr>要素の内部では「in row」モードなど、様々なモードが存在します。各モードには、特定のタグが検出されたときにどのようにDOMツリーを操作するか(要素を追加する、既存の要素を閉じる、無視するなど)のルールが定義されています。

このコミットで関連するのは、inRowIM(in row insertion mode)です。このモードでは、<tr>要素の内部で新しい<tr>タグが検出された場合、既存の<tr>を閉じてから新しい<tr>を開始するというルールが適用されます。

スタックとスコープ

HTMLパーサーは、要素の開始タグと終了タグを追跡するために内部的にスタック(Open Elements Stack)を使用します。開始タグが検出されると、その要素はスタックにプッシュされ、終了タグが検出されると、対応する要素がスタックからポップされます。

また、「スコープ」という概念も重要です。特定の要素は、特定のスコープ内でのみ有効です。例えば、<tr>要素は「テーブルスコープ」内で有効であり、<td><th>は「テーブル行コンテキストスコープ」内で有効です。パーサーは、要素が正しいスコープ内にあることを確認し、必要に応じてスタックを操作して要素を閉じたり、コンテキストをクリアしたりします。

  • tableRowContextStopTags: <tr>要素のコンテキストをクリアする際に停止するタグのセット。
  • tableScopeStopTags: テーブルスコープ内で停止するタグのセット。

技術的詳細

このコミットの主要な変更は、Go言語のhtmlパッケージ内のparse.goファイルにあります。このファイルは、HTMLドキュメントをパースし、DOMツリーを構築するロジックを含んでいます。

変更の中心は、inRowIM(in row insertion mode)関数内の処理です。この関数は、パーサーが現在<tr>要素の内部にいるときに、次のトークンをどのように処理するかを決定します。

inRowIM関数の変更点

  1. StartTagTokenの処理:

    • tdまたはthタグが検出された場合:
      • 変更前: // TODO: clear the stack back to a table row context. とコメントアウトされており、具体的な処理が実装されていませんでした。
      • 変更後: p.clearStackToContext(tableRowContextStopTags) が追加されました。これは、スタックをtableRowContextStopTags<td><th>の親要素である<tr>など)までクリアすることを意味します。これにより、新しい<td><th>が開始される前に、現在の<tr>コンテキストが適切に準備されます。
    • caption, col, colgroup, tbody, tfoot, thead, trタグが検出された場合:
      • 変更前: これらのタグに対する明示的な処理はありませんでした。
      • 変更後: if p.popUntil(tableScopeStopTags, "tr") { return inTableBodyIM, false } が追加されました。これは、スタックからtableScopeStopTags(テーブル関連の要素)まで要素をポップし、その過程で"tr"要素が見つかった場合に、inTableBodyIM(in table body insertion mode)に遷移することを示します。これは、新しい<tr>や他のテーブル関連要素が開始されたときに、既存の<tr>を暗黙的に閉じるための重要なロジックです。その後、// Ignore the token. とコメントされており、このトークン自体は無視されますが、スタックの操作によってDOMツリーは修正されます。
  2. EndTagTokenの処理:

    • trタグが検出された場合:
      • 変更前: if !p.elementInScope(tableScopeStopTags, "tr") { return inRowIM, true } というチェックがあり、trがスコープ内にない場合は無視していました。その後、p.clearStackToContext(tableRowContextStopTags)p.oe.pop() でスタックをクリアし、trをポップしていました。
      • 変更後: if p.popUntil(tableScopeStopTags, "tr") { return inTableBodyIM, true } に変更されました。これは、tr要素がスコープ内に存在し、かつtableScopeStopTagsまでポップする過程でtrが見つかった場合に、inTableBodyIMに遷移することを示します。これにより、<tr>の終了タグが検出されたときに、適切に<tr>を閉じ、テーブルボディの挿入モードに戻るようになります。elseブロックでは、// Ignore the token. とコメントされており、trが適切に閉じられない場合はトークンを無視します。

これらの変更は、HTML5のパースアルゴリズムにおける「in row」挿入モードのルールに厳密に準拠するためのものです。特に、新しい<tr>タグが開始されたときに、既存の<tr>タグが自動的に閉じられるようにすることで、不正なHTML入力に対しても正しいDOMツリーを構築できるようになります。

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

src/pkg/html/parse.go

--- a/src/pkg/html/parse.go
+++ b/src/pkg/html/parse.go
@@ -943,22 +943,27 @@ func inRowIM(p *parser) (insertionMode, bool) {
 	case StartTagToken:
 		switch p.tok.Data {
 		case "td", "th":
-			// TODO: clear the stack back to a table row context.
+			p.clearStackToContext(tableRowContextStopTags)
 			p.addElement(p.tok.Data, p.tok.Attr)
 			p.afe = append(p.afe, &scopeMarker)
 			return inCellIM, true
+		case "caption", "col", "colgroup", "tbody", "tfoot", "thead", "tr":
+			if p.popUntil(tableScopeStopTags, "tr") {
+				return inTableBodyIM, false
+			}
+			// Ignore the token.
+			return inRowIM, true
 		default:
 			// TODO.
 		}
 	case EndTagToken:
 		switch p.tok.Data {
 		case "tr":
-			if !p.elementInScope(tableScopeStopTags, "tr") {
-				return inRowIM, true
+			if p.popUntil(tableScopeStopTags, "tr") {
+				return inTableBodyIM, true
 			}
-			p.clearStackToContext(tableRowContextStopTags)
-			p.oe.pop()
-			return inTableBodyIM, true
+			// Ignore the token.
+			return inRowIM, true
 		case "table":
 			if p.popUntil(tableScopeStopTags, "tr") {
 				return inTableBodyIM, false

src/pkg/html/parse_test.go

--- a/src/pkg/html/parse_test.go
+++ b/src/pkg/html/parse_test.go
@@ -133,7 +133,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 < 86; i++ {
+		for i := 0; i < 87; i++ {
 			// Parse the #data section.
 			b, err := ioutil.ReadAll(<-rc)
 			if err != nil {

コアとなるコードの解説

src/pkg/html/parse.go

inRowIM 関数内の StartTagToken 処理

  • case "td", "th": の変更:

    • 変更前は// TODO: clear the stack back to a table row context.というコメントがあり、<td><th>タグが<tr>内で開始された際のスタッククリア処理が未実装でした。
    • 変更後、p.clearStackToContext(tableRowContextStopTags)が追加されました。これは、新しいセル(<td>または<th>)が開始される前に、現在の<tr>コンテキスト内の不要な要素をスタックから取り除き、<tr>が適切に親要素として機能するようにします。これにより、例えば<tr><td><td>のような場合に、最初の<td>が適切に閉じられてから2番目の<td>が開始されるような挙動が実現されます。
  • case "caption", "col", "colgroup", "tbody", "tfoot", "thead", "tr": の追加:

    • これらのタグは、<tr>の内部で開始されると、現在の<tr>を暗黙的に閉じるべき要素です。
    • if p.popUntil(tableScopeStopTags, "tr") { return inTableBodyIM, false } が追加されました。
      • p.popUntil(tableScopeStopTags, "tr") は、パーサーの要素スタックをtableScopeStopTags(テーブル関連の要素、例えば<table>, <tbody>など)まで遡りながら要素をポップし、その過程で"tr"要素が見つかった場合にtrueを返します。
      • この条件がtrueの場合、つまり現在の<tr>がスタックからポップされた場合、パーサーはinTableBodyIM(テーブルボディ挿入モード)に遷移します。これは、新しいテーブル関連要素が開始されたため、現在の行の処理を終了し、テーブルボディのコンテキストに戻ることを意味します。
      • return inTableBodyIM, falsefalse は、現在のトークン(例えば新しい<tr>タグ)がまだ処理されていないことを示し、パーサーは新しいモードでそのトークンを再処理します。
    • // Ignore the token. は、この特定のトークン自体はDOMツリーに追加されないが、スタック操作によってDOM構造が修正されることを示唆しています。

inRowIM 関数内の EndTagToken 処理

  • case "tr": の変更:
    • 変更前は、trがスコープ内にない場合に無視し、そうでない場合はclearStackToContextpoptrを閉じていました。
    • 変更後、if p.popUntil(tableScopeStopTags, "tr") { return inTableBodyIM, true } に変更されました。
      • これは、<tr>の終了タグが検出されたときに、スタックをtableScopeStopTagsまでポップし、その過程で"tr"要素が見つかった場合に、inTableBodyIMに遷移することを意味します。
      • return inTableBodyIM, truetrue は、現在のトークン(</tr>)が正常に処理されたことを示します。
    • elseブロックの // Ignore the token. は、trが適切に閉じられない(例えば、対応する開始タグがない)場合は、この終了タグを無視することを示します。

src/pkg/html/parse_test.go

  • for i := 0; i < 86; i++ { から for i := 0; i < 87; i++ { への変更:
    • これは、テストスイートが処理するテストケースの数を86から87に増やしたことを意味します。
    • コミットメッセージに記載されている「Pass tests1.dat, test 87」という記述から、この変更が、修正されたバグを検証するための新しいテストケース(tests1.datの87番目のテスト)をテストスイートに含めるために行われたことがわかります。これにより、修正が正しく機能していることを自動的に確認できるようになります。

これらの変更により、GoのHTMLパーサーは、HTML5の仕様に準拠し、特にテーブル要素のパースにおいて、より堅牢で正確なDOMツリーを構築できるようになりました。

関連リンク

  • Go言語のコードレビューシステム: https://golang.org/cl/5343041

参考にした情報源リンク