[インデックス 12969] ファイルの概要
このコミットは、Go言語の実験的なHTMLパーサーであるexp/html
パッケージにおける、HTMLテーブルのボディ(<tbody>
要素など)のパースロジックを修正するものです。具体的には、src/pkg/exp/html/parse.go
内のinTableBodyIM
関数がHTML仕様に厳密に準拠するように調整され、関連するテストログファイルsrc/pkg/exp/html/testlogs/tests_innerHTML_1.dat.log
も更新されています。
コミット
commit a09e9811dc4db3c9205079a2eef21ffc7d7b5274
Author: Andrew Balholm <andybalholm@gmail.com>
Date: Thu Apr 26 11:48:35 2012 +1000
exp/html: adjust inTableBodyIM to match spec
Clean up flow of control.
Handle </tbody>, </tfoot>, and </thead>.
Pass 5 additional tests.
R=nigeltao
CC=golang-dev
https://golang.org/cl/6117057
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/a09e9811dc4db3c9205079a2eef21ffc7d7b5274
元コミット内容
このコミットは、Go言語のexp/html
パッケージにおいて、HTMLパーサーのinTableBodyIM
("in table body insertion mode")の挙動をHTML仕様に合致させることを目的としています。具体的には、制御フローの整理、</tbody>
、</tfoot>
、</thead>
といった終了タグの適切な処理、そして5つの追加テストのパスを実現しています。
変更の背景
HTMLのパースは非常に複雑であり、特にテーブル要素の構造は厳密なルールに基づいています。ブラウザは、不正なHTMLマークアップに対しても、HTML Living Standard(WHATWGによって維持されているHTMLの最新仕様)に定められたアルゴリズムに従ってエラー回復処理を行います。
このコミットが行われた背景には、exp/html
パーサーがHTML仕様、特にテーブルボディ内の要素の処理に関するルールに完全に準拠していなかったという問題があります。具体的には、<tbody>
、<thead>
、<tfoot>
といったテーブルセクショニング要素の終了タグや、<tr>
、<td>
、<th>
といったテーブルコンテンツ要素の開始タグが、テーブルボディの挿入モードにおいてどのように処理されるべきかについて、既存の実装が仕様と異なっていた可能性があります。
この不一致は、特定の不正なHTML構造をパースした際に、DOMツリーがブラウザの挙動と異なる結果になる原因となります。そのため、パーサーの堅牢性と互換性を向上させるために、inTableBodyIM
のロジックを仕様に合わせて調整する必要がありました。これにより、より多くのテストケース(特にテーブル関連)がパスするようになり、パーサーの正確性が向上します。
前提知識の解説
Go言語のexp/html
パッケージ
exp/html
は、Go言語で書かれたHTML5パーサーの実験的なパッケージです。これは、WHATWG HTML Living Standardに記述されているパースアルゴリズムを実装しており、ウェブブラウザがHTMLドキュメントをどのようにDOMツリーに変換するかを模倣することを目指しています。このパッケージは、HTMLドキュメントの解析、DOMツリーの構築、そしてHTMLのサニタイズなどに利用されます。
HTMLパーシングの概要と「挿入モード (Insertion Mode)」
HTMLパーシングは、HTMLドキュメントのバイトストリームをトークンに変換し(トークナイゼーション)、そのトークン列を基にDOMツリーを構築するプロセスです。HTML5のパースアルゴリズムは、状態機械として定義されており、その中心的な概念の一つが「挿入モード (Insertion Mode)」です。
挿入モードは、パーサーが現在処理しているHTMLドキュメントのどの部分にいるかに応じて、異なるトークン処理ルールを適用するための状態です。例えば、<head>
要素内では特定のタグのみが許可され、それ以外のタグは異なる方法で処理されます。テーブル関連の要素をパースする際には、以下のような特定の挿入モードが存在します。
- "in body" insertion mode: 通常のHTMLコンテンツをパースする際のデフォルトモード。
- "in table" insertion mode:
<table>
タグが開始された後に遷移するモード。 - "in table body" insertion mode (
inTableBodyIM
):<tbody>
、<thead>
、<tfoot>
タグが開始された後に遷移するモード。 - "in row" insertion mode (
inRowIM
):<tr>
タグが開始された後に遷移するモード。
各挿入モードでは、受け取ったトークン(開始タグ、終了タグ、テキストなど)の種類に応じて、DOMツリーへの要素の追加、スタックの操作、モードの遷移など、特定のアルゴリズムが実行されます。
HTMLテーブル構造と関連要素
HTMLのテーブルは、厳密な階層構造を持っています。
<table>
: テーブルのルート要素。<caption>
: テーブルのキャプション(オプション)。<thead>
: テーブルのヘッダーセクション(オプション)。<tbody>
: テーブルのボディセクション(デフォルトで存在し、通常は複数存在可能)。<tfoot>
: テーブルのフッターセクション(オプション)。<tr>
: テーブルの行。<thead>
,<tbody>
,<tfoot>
の子要素として配置される。<td>
: テーブルのデータセル。<tr>
の子要素として配置される。<th>
: テーブルのヘッダーセル。<tr>
の子要素として配置される。
HTML仕様では、これらの要素が特定の順序で出現しなかったり、不正なネストをしていたりする場合に、パーサーがどのようにDOMツリーを修正すべきかが詳細に定義されています。例えば、<tbody>
が明示的に閉じられていない状態で別の<tbody>
や<tfoot>
、<thead>
が来た場合、現在の<tbody>
は暗黙的に閉じられるべき、といったルールがあります。
HTMLパーシングにおける「スタック (Stack of open elements)」と「スコープ (Scope)」
HTMLパーサーは、現在開いている要素を追跡するために「開いている要素のスタック (stack of open elements)」を使用します。新しい要素が開始されると、その要素はスタックにプッシュされ、要素が閉じられるとスタックからポップされます。このスタックは、DOMツリーの階層構造を構築し、要素のネストが正しいかを判断するために不可欠です。
「スコープ (Scope)」は、特定の要素が特定のコンテキスト内で「スコープ内にある」と見なされるかどうかを判断するための概念です。例えば、テーブル関連の要素は「テーブルスコープ (table scope)」内で特定の挙動を示します。popUntil
やclearStackToContext
のような関数は、このスタックとスコープの概念を利用して、特定の要素が見つかるまでスタックをポップしたり、特定のスコープ内の要素をクリアしたりします。
tableScope
:<table>
要素がスタック上にあることを示すスコープ。tableBodyScope
:<tbody>
、<thead>
、<tfoot>
要素がスタック上にあることを示すスコープ。このコミットで新しく追加されたスコープです。
技術的詳細
このコミットの主要な変更点は、src/pkg/exp/html/parse.go
内のinTableBodyIM
関数のロジックをHTML仕様に厳密に合わせることにあります。
tableBodyScope
の追加
以前は存在しなかったtableBodyScope
という新しいスコープが追加されました。
const (
// ...
tableRowScope
tableBodyScope // 新しく追加
)
このスコープは、<tbody>
、<thead>
、<tfoot>
要素がスタック上にある状態を明確に識別するために使用されます。これにより、clearStackToContext
関数がこれらの要素を適切に処理できるようになります。
clearStackToContext
関数の変更
clearStackToContext
関数は、特定のスコープ内の要素が見つかるまでスタックをクリアする役割を担います。このコミットでは、tableBodyScope
が追加されたことにより、tableBodyScope
の場合の処理が追加されました。
case tableBodyScope:
if tag == "html" || tag == "tbody" || tag == "tfoot" || tag == "thead" {
p.oe = p.oe[:i+1]
return
}
これは、tableBodyScope
内でhtml
、tbody
、tfoot
、thead
タグが見つかった場合、その要素までスタックをクリアすることを意味します。これにより、テーブルボディ内の不正なネストが検出された際に、パーサーが適切な状態にリセットされるようになります。
inColumnGroupIM
でのTextToken
処理の変更
inColumnGroupIM
("in column group insertion mode")関数において、TextToken
(テキストノード)の処理が追加されました。
case TextToken:
s := strings.TrimLeft(p.tok.Data, whitespace)
if len(s) < len(p.tok.Data) {
// Add the initial whitespace to the current node.
p.addText(p.tok.Data[:len(p.tok.Data)-len(s)])
if s == "" {
return true
}
p.tok.Data = s
}
これは、colgroup
要素内でテキストノード(特に空白文字)が検出された場合、その空白文字を現在のノードに追加し、残りの非空白文字を次の処理のために保持するという挙動を実装しています。HTML仕様では、colgroup
要素内ではテキストノードは通常許可されませんが、空白文字は無視されるか、特定のコンテキストで処理される場合があります。この変更は、その仕様に合わせたものと考えられます。
inTableBodyIM
関数の大幅な変更
inTableBodyIM
関数は、テーブルボディの挿入モードにおけるトークンの処理ロジックを定義しています。この関数は、HTML仕様の「12.2.5.4.13 The "in table body" insertion mode」セクションに準拠するように全面的に見直されました。
変更前:
変更前は、add
、data
、attr
、consumed
といった複数のフラグや変数を使い、複雑な条件分岐で処理を制御していました。特に、tr
、td
、th
の開始タグに対する処理が冗長で、popUntil
の呼び出しも仕様と異なる可能性がありました。
変更後: 変更後は、より直接的にHTML仕様のアルゴリズムを反映するように簡素化されています。
-
StartTagToken
の処理:tr
タグ:
これは、case "tr": p.clearStackToContext(tableBodyScope) // tableBodyScopeまでスタックをクリア p.addElement(p.tok.Data, p.tok.Attr) // tr要素を追加 p.im = inRowIM // in row insertion modeへ遷移 return true
<tbody>
内で<tr>
が開始された場合、まずtableBodyScope
までスタックをクリアし(これにより、暗黙的に閉じられるべき要素が閉じられる)、次に<tr>
要素を追加し、挿入モードをinRowIM
("in row insertion mode")に遷移させるという仕様通りの挙動を実装しています。td
,th
タグ:case "td", "th": p.parseImpliedToken(StartTagToken, "tr", nil) // 暗黙的にtrタグをパース return false
<tbody>
内で<td>
や<th>
が直接出現した場合、HTML仕様では暗黙的に<tr>
要素が挿入されることになっています。この変更は、parseImpliedToken
を使ってこの暗黙的な<tr>
の挿入を処理し、現在のトークン(<td>
または<th>
)を再処理するためにfalse
を返しています。caption
,col
,colgroup
,tbody
,tfoot
,thead
タグ:
これらのタグがcase "caption", "col", "colgroup", "tbody", "tfoot", "thead": if p.popUntil(tableScope, "tbody", "thead", "tfoot") { // tableScopeまで、tbody/thead/tfootを考慮してポップ p.im = inTableIM // in table insertion modeへ遷移 return false } // Ignore the token. return true
inTableBodyIM
で出現した場合、HTML仕様では現在のテーブルボディ要素を閉じ、inTableIM
("in table insertion mode")に遷移して、現在のトークンを再処理するよう指示されています。popUntil
は、tableScope
までスタックをポップし、その際にtbody
、thead
、tfoot
要素を考慮します。これにより、現在のテーブルボディが適切に閉じられ、パーサーがテーブルモードに戻ります。popUntil
が成功した場合(true
を返す)、トークンを再処理するためにfalse
を返します。成功しなかった場合はトークンを無視します。
-
EndTagToken
の処理:tbody
,tfoot
,thead
タグ:
これらの終了タグが検出された場合、対応する開始タグがcase "tbody", "tfoot", "thead": if p.elementInScope(tableScope, p.tok.Data) { // tableScope内に現在の終了タグに対応する要素があるか確認 p.clearStackToContext(tableBodyScope) // tableBodyScopeまでスタックをクリア p.oe.pop() // 現在の要素(tbody/tfoot/thead)をポップ p.im = inTableIM // in table insertion modeへ遷移 } return true
tableScope
内に存在すれば、tableBodyScope
までスタックをクリアし、現在の要素をスタックからポップし、inTableIM
に遷移します。これは、テーブルセクショニング要素の終了処理を正確に実装したものです。table
タグ:table
終了タグの処理は、inTableBodyIM
からinTableIM
に遷移し、現在のトークンを再処理するように変更はありませんが、popUntil
の条件がより明確になりました。
-
その他のトークン:
ErrorToken
や未処理のTextToken
、その他のStartTagToken
/EndTagToken
は、最終的にinTableIM(p)
にフォールバックするように変更されました。これは、HTML仕様の「Anything else」のルールに相当し、現在のモードで処理できないトークンは、親のモード(この場合はinTableIM
)で処理を試みるという挙動です。
テストログの変更
src/pkg/exp/html/testlogs/tests_innerHTML_1.dat.log
ファイルは、このコミットによって5つのテストケースがFAIL
からPASS
に変わったことを示しています。
--- a/src/pkg/exp/html/testlogs/tests_innerHTML_1.dat.log
+++ b/src/pkg/exp/html/testlogs/tests_innerHTML_1.dat.log
@@ -43,11 +43,11 @@ PASS "<tbody><a>"
PASS "<tfoot><a>"
PASS "<thead><a>"
PASS "</table><a>"
-FAIL "<a><tr>"
-FAIL "<a><td>"
-FAIL "<a><td>"
-FAIL "<a><td>"
-FAIL "<a><td><table><tbody><a><tr>"
+PASS "<a><tr>"
+PASS "<a><td>"
+PASS "<a><td>"
+PASS "<a><td>"
+PASS "<a><td><table><tbody><a><tr>"
PASS "</tr><td>"
PASS "<td><table><a><tr></tr><tr>"
PASS "<caption><td>"
特に注目すべきは、FAIL "<a><tr>"
や FAIL "<a><td>"
といったテストケースがPASS
になっている点です。これは、inTableBodyIM
が<tr>
や<td>
タグを適切に処理し、暗黙的な<tr>
の挿入などのエラー回復ロジックが正しく機能するようになったことを示しています。これらのテストは、不正なHTML構造(例えば、<tbody>
要素が明示的に開かれていない状態で<tr>
や<td>
が出現するケース)に対するパーサーの挙動を検証するものです。
コアとなるコードの変更箇所
diff --git a/src/pkg/exp/html/parse.go b/src/pkg/exp/html/parse.go
index 08f029c63e..ba1ff0b447 100644
--- a/src/pkg/exp/html/parse.go
+++ b/src/pkg/exp/html/parse.go
@@ -68,6 +68,7 @@ const (
buttonScope
tableScope
tableRowScope
+ tableBodyScope // 追加
)
// popUntil pops the stack of open elements at the highest element whose tag
@@ -160,6 +161,11 @@ func (p *parser) clearStackToContext(s scope) {
// ...
}
+ case tableBodyScope: // tableBodyScopeのケースを追加
+ if tag == "html" || tag == "tbody" || tag == "tfoot" || tag == "thead" {
+ p.oe = p.oe[:i+1]
+ return
+ }
default:
panic("unreachable")
}
@@ -1290,6 +1296,16 @@ func inCaptionIM(p *parser) bool {
// Section 12.2.5.4.12.
func inColumnGroupIM(p *parser) bool {
switch p.tok.Type {
+ case TextToken: // TextTokenの処理を追加
+ s := strings.TrimLeft(p.tok.Data, whitespace)
+ if len(s) < len(p.tok.Data) {
+ // Add the initial whitespace to the current node.
+ p.addText(p.tok.Data[:len(p.tok.Data)-len(s)])
+ if s == "" {
+ return true
+ }
+ p.tok.Data = s
+ }
case CommentToken:
// ...
}
@@ -1332,40 +1348,34 @@ func inColumnGroupIM(p *parser) bool {
// Section 12.2.5.4.13.
func inTableBodyIM(p *parser) bool {
- var ( // 削除された変数宣言
- add bool
- data string
- attr []Attribute
- consumed bool
- )
switch p.tok.Type {
- case ErrorToken: // 削除
- // TODO.
- case TextToken: // 削除
- // TODO.
case StartTagToken:
switch p.tok.Data {
case "tr":
- add = true // 削除
- data = p.tok.Data // 削除
- attr = p.tok.Attr // 削除
- consumed = true // 削除
+ p.clearStackToContext(tableBodyScope) // 変更
+ p.addElement(p.tok.Data, p.tok.Attr) // 変更
+ p.im = inRowIM // 変更
+ return true // 変更
case "td", "th":
- add = true // 削除
- data = "tr" // 削除
- consumed = false // 削除
+ p.parseImpliedToken(StartTagToken, "tr", nil) // 変更
+ return false // 変更
case "caption", "col", "colgroup", "tbody", "tfoot", "thead":
- if !p.popUntil(tableScope, "tbody", "thead", "tfoot") { // 変更
- // Ignore the token.
- return true
+ if p.popUntil(tableScope, "tbody", "thead", "tfoot") { // 変更
+ p.im = inTableIM // 変更
+ return false // 変更
}
- p.im = inTableIM // 削除
- return false // 削除
- default: // 削除
- // TODO.
+ // Ignore the token. // 変更
+ return true // 変更
}
case EndTagToken:
switch p.tok.Data {
+ case "tbody", "tfoot", "thead": // 追加
+ if p.elementInScope(tableScope, p.tok.Data) {
+ p.clearStackToContext(tableBodyScope)
+ p.oe.pop()
+ p.im = inTableIM
+ }
+ return true
case "table":
if p.popUntil(tableScope, "tbody", "thead", "tfoot") {
p.im = inTableIM
@@ -1384,12 +1394,7 @@ func inTableBodyIM(p *parser) bool {
})\n\t\treturn true\n\t}\n- if add { // 削除されたロジック
- // TODO: clear the stack back to a table body context.
- p.addElement(data, attr)
- p.im = inRowIM
- return consumed
- }\n+\n \treturn inTableIM(p)\n }\n \n```
## コアとなるコードの解説
### `tableBodyScope`の導入
`const`ブロックに`tableBodyScope`が追加されました。これは、HTMLパーサーがテーブルボディ関連の要素(`<tbody>`, `<thead>`, `<tfoot>`)を処理する際に、現在のコンテキストをより正確に識別するための新しい「スコープ」を定義します。これにより、パーサーはこれらの要素の開始と終了、およびそれらの内部での他の要素の出現を、HTML仕様に沿ってより厳密に管理できるようになります。
### `clearStackToContext`の拡張
`clearStackToContext`関数は、開いている要素のスタックを特定のスコープまでクリアする役割を担います。このコミットでは、新しく定義された`tableBodyScope`に対する処理が追加されました。
`case tableBodyScope:`ブロックが追加され、スタックをクリアする際に`html`、`tbody`、`tfoot`、`thead`タグを考慮するようになりました。これは、テーブルボディ内でこれらの要素が検出された場合に、パーサーがスタックを適切に調整し、DOMツリーの整合性を保つために重要です。例えば、`<tbody>`がまだ開いている状態で別の`<thead>`が来た場合、既存の`<tbody>`は暗黙的に閉じられるべきであり、このロジックがそれを実現します。
### `inColumnGroupIM`における`TextToken`の処理
`inColumnGroupIM`関数(`colgroup`要素の挿入モード)に`TextToken`(テキストノード)の処理が追加されました。
この変更は、`colgroup`要素内で空白文字が検出された場合の挙動を定義しています。`strings.TrimLeft`を使用して先行する空白文字をトリムし、その空白文字を現在のノードに追加します。残りの非空白文字は、次の処理のために`p.tok.Data`に保持されます。これは、HTML仕様における`colgroup`要素内のテキスト処理、特に空白文字の扱いに関するルールに準拠するためのものです。
### `inTableBodyIM`のロジック刷新
`inTableBodyIM`関数は、このコミットの最も重要な変更点です。以前の複雑で冗長なロジックが削除され、HTML仕様の「in table body insertion mode」のアルゴリズムに直接対応する、よりクリーンで正確な実装に置き換えられました。
* **`StartTagToken`の処理:**
* **`tr`タグ:** `tr`開始タグが検出されると、まず`p.clearStackToContext(tableBodyScope)`が呼び出され、現在のテーブルボディ関連の要素が適切に閉じられます。その後、`tr`要素がDOMツツリーに追加され、パーサーの挿入モードが`inRowIM`(行の挿入モード)に遷移します。これは、`<tbody>`内で`<tr>`が開始された場合の標準的な挙動です。
* **`td`, `th`タグ:** `td`または`th`開始タグが検出された場合、HTML仕様では暗黙的に`tr`要素が挿入されることになっています。`p.parseImpliedToken(StartTagToken, "tr", nil)`は、この暗黙的な`tr`要素の挿入をシミュレートします。`return false`は、現在の`td`または`th`トークンを再処理させることを意味し、これにより`tr`が挿入された後に`td`/`th`がその子として適切にパースされます。
* **`caption`, `col`, `colgroup`, `tbody`, `tfoot`, `thead`タグ:** これらのタグが`inTableBodyIM`で出現した場合、`p.popUntil(tableScope, "tbody", "thead", "tfoot")`が呼び出されます。これは、`tableScope`までスタックをポップし、その際に`tbody`、`thead`、`tfoot`要素を考慮して、現在のテーブルボディを適切に閉じます。成功した場合、パーサーは`inTableIM`(テーブルの挿入モード)に遷移し、現在のトークンを再処理します。これにより、これらの要素がテーブルボディ内で不正にネストされた場合に、パーサーが正しい状態に回復します。
* **`EndTagToken`の処理:**
* **`tbody`, `tfoot`, `thead`タグ:** これらの終了タグが検出された場合、`p.elementInScope(tableScope, p.tok.Data)`で対応する開始タグが`tableScope`内に存在するかを確認します。存在すれば、`p.clearStackToContext(tableBodyScope)`でスタックをクリアし、`p.oe.pop()`で現在の要素をスタックからポップし、`p.im = inTableIM`でテーブルモードに遷移します。これは、テーブルセクショニング要素の終了処理を正確に実装したものです。
* **`table`タグ:** `table`終了タグの処理は、`inTableIM`に遷移してトークンを再処理するという点で変更はありませんが、`popUntil`の条件がより明確になりました。
* **冗長な変数の削除とフォールバックロジックの簡素化:**
以前の`add`, `data`, `attr`, `consumed`といった一時変数は削除され、ロジックが直接的になりました。また、未処理のトークンに対するフォールバックロジックも簡素化され、最終的に`inTableIM(p)`に処理を委ねる形になりました。これは、HTML仕様の「Anything else」のルールに合致するものです。
これらの変更により、`exp/html`パーサーはHTMLテーブルのパースにおいて、より堅牢で仕様に準拠した挙動を示すようになりました。特に、不正なHTMLマークアップに対するエラー回復能力が向上し、ブラウザの挙動との互換性が高まっています。
## 関連リンク
* [https://golang.org/cl/6117057](https://golang.org/cl/6117057) (Go Code Review)
## 参考にした情報源リンク
* [HTML Living Standard - 12.2.5.4.13 The "in table body" insertion mode](https://html.spec.whatwg.org/multipage/parsing.html#in-table-body-insertion-mode)
* [HTML Living Standard - 12.2.5.4.7 The "in table" insertion mode](https://html.spec.whatwg.org/multipage/parsing.html#in-table-insertion-mode)
* [HTML Living Standard - 12.2.5.4.14 The "in row" insertion mode](https://html.spec.whatwg.org/multipage/parsing.html#in-row-insertion-mode)
* [HTML Living Standard - 12.2.5.4.12 The "in column group" insertion mode](https://html.spec.whatwg.org/multipage/parsing.html#in-column-group-insertion-mode)
* [HTML Living Standard - 12.2.5.2 The stack of open elements](https://html.spec.whatwg.org/multipage/parsing.html#the-stack-of-open-elements)
* [HTML Living Standard - 12.2.5.3 Scopes](https://html.spec.whatwg.org/multipage/parsing.html#scopes)
* [GoDoc - exp/html](https://pkg.go.dev/exp/html)