[インデックス 10800] ファイルの概要
このコミットは、Go言語のHTMLパーサー(src/pkg/html
パッケージ)におけるバグ修正です。具体的には、HTMLのテーブル要素内で<colgroup>
要素を処理する際の「in column group」モードにおいて、不正なトークンが無視された後もパーサーが正しい状態遷移を維持できるようにする変更です。これにより、特定の不正なHTML構造(例: foo<col>
や <table><tr><div><td>
)が与えられた場合に、パーサーが期待通りに動作し、テストケースをパスするようになります。
コミット
commit 85fdd68bd963406a90ec68e1532bc6495e88e40b
Author: Andrew Balholm <andybalholm@gmail.com>
Date: Thu Dec 15 10:45:19 2011 +1100
html: don't leave "in column group" mode when ignoring a token
Pass tests6.dat, test 26:
foo<col>
| <col>
Also pass tests through test 35:
<table><tr><div><td>
R=nigeltao
CC=golang-dev
https://golang.org/cl/5482074
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/85fdd68bd963406a90ec68e1532bc6495e88e40b
元コミット内容
HTMLパーサーが「in column group」モードにあるとき、トークンを無視した場合にそのモードを終了しないようにする修正。これにより、tests6.dat
のテスト26(foo<col>
)およびテスト35までの他のテスト(<table><tr><div><td>
など)がパスするようになる。
変更の背景
HTMLのパースは、非常に複雑なプロセスであり、特に不正なマークアップが与えられた場合の挙動は、HTML5の仕様で厳密に定義されています。このコミットの背景には、Go言語のHTMLパーサーが、特定の不正なHTML構造を処理する際に、HTML5の仕様に準拠した正しい状態遷移を行えていなかったという問題があります。
具体的には、<colgroup>
要素の内部でパーサーが「in column group」モードにあるとき、仕様上無視されるべきトークン(例えば、<col>
以外の要素やテキストノードなど)が出現した場合に、パーサーがこのモードを適切に維持せず、誤ったモードに遷移してしまうことがありました。これにより、後続の正しいHTML要素が誤って解釈されたり、DOMツリーが期待通りに構築されなかったりするバグが発生していました。
コミットメッセージに記載されている tests6.dat, test 26: foo<col>
は、<col>
要素が<colgroup>
の外部に現れた場合のテストケースを示唆しています。HTML5の仕様では、<col>
要素は<colgroup>
要素の直接の子としてのみ有効であり、それ以外の場所に出現した場合は無視されるべきです。しかし、この無視処理の後にパーサーが誤った状態に遷移していたため、テストが失敗していました。
また、<table><tr><div><td>
のような構造も、HTMLのテーブル構造の厳密なルールに違反しています。<div>
要素は<table>
や<tr>
の直接の子としては許可されていません。このような不正なマークアップに対しても、HTMLパーサーは堅牢に、かつ仕様に沿ってエラーを処理し、可能な限りDOMツリーを構築する必要があります。このコミットは、これらのエッジケースにおけるパーサーの堅牢性と正確性を向上させることを目的としています。
前提知識の解説
HTMLのテーブル構造と要素
HTMLのテーブルは、データを表形式で表示するための複雑な構造を持っています。主要な要素は以下の通りです。
<table>
: テーブルのルート要素。<thead>
: テーブルのヘッダー行のグループ。<tbody>
: テーブルの本体行のグループ。<tfoot>
: テーブルのフッター行のグループ。<tr>
: テーブルの行。<td>
: テーブルのデータセル。<th>
: テーブルのヘッダーセル。<colgroup>
: テーブルの列のグループを定義します。この要素は、テーブルの列にスタイルを適用したり、構造的なグループ化を行ったりするために使用されます。<table>
要素の直接の子として、<caption>
要素の後に、<thead>
,<tbody>
,<tfoot>
の前に配置されます。<col>
:<colgroup>
要素の内部で使用され、個々の列または列のグループにスタイルや属性を適用します。
HTMLパーサーの動作原理と挿入モード (Insertion Mode)
HTMLパーサーは、HTMLドキュメントを読み込み、それをブラウザがレンダリングできるDOM (Document Object Model) ツリーに変換するソフトウェアです。HTML5のパースアルゴリズムは、非常に詳細かつ堅牢に定義されており、不正なHTMLに対してもエラー回復メカニズムを備えています。
HTML5のパースアルゴリズムの重要な概念の一つに「挿入モード (Insertion Mode)」があります。これは、パーサーが現在処理しているHTMLのコンテキストに基づいて、次にどのようなトークンを期待し、どのようにDOMツリーを構築するかを決定する状態機械のようなものです。パーサーは、入力ストリームからトークンを読み込むたびに、現在の挿入モードとトークンの種類に基づいて、次のアクション(要素の挿入、テキストノードの追加、エラー処理、モードの変更など)を決定します。
例えば、<table>
要素をパースしているときは「in table」モード、<tr>
要素をパースしているときは「in row」モード、そして<colgroup>
要素をパースしているときは「in column group」モードに遷移します。各モードには、特定のトークンが来た場合の処理ルールが定義されています。不正なトークンが来た場合、パーサーはエラーを回復しようと試み、場合によっては現在の要素を閉じたり、別の挿入モードに遷移したりします。
Go言語の html
パッケージ (golang.org/x/net/html
)
Go言語の標準ライブラリには、HTMLをパースするためのgolang.org/x/net/html
パッケージがあります。このパッケージは、HTML5のパースアルゴリズムに準拠しており、HTMLドキュメントをDOMツリーとして表現する機能を提供します。
html.Parse()
:io.Reader
からHTMLを読み込み、DOMツリーのルートノードを返します。html.Node
: DOMツリーの各ノードを表す構造体で、Type
(要素、テキスト、コメントなど)、Data
(タグ名やテキスト内容)、Attr
(属性)、FirstChild
,NextSibling
,Parent
などのフィールドを持ちます。- パーサー内部では、要素スタック(
p.oe
)が使用され、現在開いている要素の階層構造を追跡します。p.oe.top()
はスタックの最上位(現在処理中の要素)を返します。p.oe.pop()
はスタックの最上位から要素を削除します。 p.im
は、パーサーの現在の挿入モードを表すフィールドです。inColumnGroupIM
やinTableIM
は、それぞれ「in column group」モードと「in table」モードを処理する関数(または状態)を指します。
技術的詳細
このコミットが修正しているのは、src/pkg/html/parse.go
内の inColumnGroupIM
関数です。この関数は、HTMLパーサーが「in column group」挿入モードにあるときに呼び出され、入力トークンを処理します。
HTML5の仕様では、「in column group」モードにおいて、<col>
タグ以外のトークンが出現した場合、そのトークンは無視されるべきです。しかし、無視された後もパーサーは「in column group」モードを維持するか、あるいは適切なモードに遷移する必要があります。
元のコードでは、<col>
以外のトークンが来た場合、または</colgroup>
タグが来た場合に、パーサーはp.oe.pop()
(要素スタックから現在の要素をポップ)し、p.im = inTableIM
(挿入モードを「in table」モードに設定)していました。
問題は、<col>
以外のトークンが無視されるべきケースにおいて、p.im = inTableIM
の設定が早すぎたことです。特に、p.oe.top().Data != "html"
の条件が真の場合(つまり、要素スタックのトップがhtml
要素ではない場合、通常は<colgroup>
要素がスタックのトップにあることを意味します)、p.oe.pop()
が実行され、<colgroup>
要素がスタックから削除されます。この後、パーサーは「in table」モードに遷移しますが、もし無視されたトークンの後にまだ<colgroup>
に関連するトークンが続く場合、パーサーはすでに「in column group」モードを離れてしまっているため、誤った処理を行う可能性がありました。
このコミットの修正は、この状態遷移のタイミングを調整することで、パーサーが不正なトークンを無視した後も、必要に応じて「in column group」モードを維持できるようにします。
コアとなるコードの変更箇所
変更は src/pkg/html/parse.go
の inColumnGroupIM
関数に集中しています。
--- a/src/pkg/html/parse.go
+++ b/src/pkg/html/parse.go
@@ -1166,8 +1166,8 @@ func inColumnGroupIM(p *parser) bool {
case "colgroup":
if p.oe.top().Data != "html" {
p.oe.pop()
+ p.im = inTableIM
}
- p.im = inTableIM
return true
case "col":
// Ignore the token.
@@ -1176,9 +1176,10 @@ func inColumnGroupIM(p *parser) bool {
}\n \tif p.oe.top().Data != "html" {\n \t\tp.oe.pop()\n+\t\tp.im = inTableIM
+\t\treturn false
\t}\n-\tp.im = inTableIM
-\treturn false
+\treturn true
}\n
// Section 12.2.5.4.13.
また、テストファイル src/pkg/html/parse_test.go
の変更も含まれています。
--- a/src/pkg/html/parse_test.go
+++ b/src/pkg/html/parse_test.go
@@ -172,7 +172,7 @@ func TestParser(t *testing.T) {
{"tests3.dat", -1},
{"tests4.dat", -1},
{"tests5.dat", -1},
- {"tests6.dat", 26},
+ {"tests6.dat", 36},
}
for _, tf := range testFiles {
f, err := os.Open("testdata/webkit/" + tf.filename)
tests6.dat
のテストケースの終了点が 26
から 36
に変更されています。これは、この修正によってより多くのテストケースがパスするようになったことを示しています。
コアとなるコードの解説
inColumnGroupIM
関数は、パーサーが<colgroup>
要素の内部を処理している状態を表します。
変更点1:
case "colgroup":
if p.oe.top().Data != "html" {
p.oe.pop()
+ p.im = inTableIM // ここに移動
}
- p.im = inTableIM // ここから削除
return true
この変更は、</colgroup>
タグ(またはcolgroup
要素を閉じるようなトークン)が来た場合の処理です。
元のコードでは、if
ブロックの内外に関わらず、常にp.im = inTableIM
が実行されていました。
修正後では、p.im = inTableIM
がif p.oe.top().Data != "html"
ブロックの内部に移動しました。
これは、要素スタックのトップがhtml
要素でない場合(つまり、有効な<colgroup>
要素がスタックのトップにある場合)にのみ、<colgroup>
要素をポップし、その後「in table」モードに遷移するというロジックを明確にしています。これにより、パーサーがcolgroup
要素を適切に閉じた後にのみ、テーブルモードに戻るようになります。
変更点2:
}\n \tif p.oe.top().Data != "html" {\n \t\tp.oe.pop()\n+\t\tp.im = inTableIM // 追加
+\t\treturn false // 変更
\t}\n-\tp.im = inTableIM // ここから削除
-\treturn false // 変更
+\treturn true // 変更
この変更は、<col>
タグでも</colgroup>
タグでもない、その他のトークンが「in column group」モードで出現した場合の処理です。HTML5の仕様では、これらのトークンは無視されるべきです。
元のコードでは、p.oe.top().Data != "html"
の条件が真の場合(つまり、<colgroup>
要素がスタックのトップにある場合)、p.oe.pop()
を実行し、その後すぐに p.im = inTableIM
を実行して「in table」モードに遷移していました。そして return false
で処理を終了していました。
修正後では、p.im = inTableIM
の行が if
ブロックの内部に移動し、さらに return false
が追加されました。そして、if
ブロックの外では return true
に変更されています。
この修正の意図は以下の通りです。
- 不正なトークンが来た場合:
p.oe.top().Data != "html"
が真の場合(つまり、<colgroup>
がスタックのトップにある場合)、<colgroup>
をポップし、p.im = inTableIM
で「in table」モードに遷移し、return false
で処理を終了します。これは、不正なトークンによって<colgroup>
のコンテキストが終了したと判断し、テーブルモードに戻ることを意味します。p.oe.top().Data != "html"
が偽の場合(つまり、<colgroup>
がスタックのトップにない、またはhtml
要素がトップにあるなど、異常な状態の場合)、p.im = inTableIM
は実行されず、return true
が実行されます。これは、現在のモードを維持し、次のトークンで再度処理を試みることを示唆しています。
この変更により、パーサーは不正なトークンを無視した後も、必要に応じて「in column group」モードを維持するか、あるいはより正確なタイミングで「in table」モードに遷移するようになります。これにより、foo<col>
のようなケースで<col>
が無視された後も、パーサーが正しい状態を保ち、後続のパースに影響を与えないようになります。
関連リンク
- Go CL 5482074: https://golang.org/cl/5482074
参考にした情報源リンク
- HTML5仕様 (W3C Recommendation): https://www.w3.org/TR/html5/ (特に、パースアルゴリズムのセクション)
- GoDoc for
golang.org/x/net/html
: https://pkg.go.dev/golang.org/x/net/html - HTML5 Parsing Algorithm Visualizer (参考): https://html5.validator.nu/ (HTML5のパースアルゴリズムの挙動を視覚的に理解するのに役立つ場合があります)
- Web search results for "golang html parser in column group mode" (上記Web検索結果より、
golang.org/x/net/html
パッケージの利用方法やHTMLテーブルのパースに関する情報)- https://webscrapingapi.com/blog/go-web-scraping-tutorial/
- https://nikodoko.com/go-html-parser-tutorial/
- https://medium.com/@saurav.sarkar/web-scraping-with-go-a-comprehensive-guide-to-golang-org-x-net-html-and-goquery-101-c72121212121
- https://zetcode.com/go/html/
- https://scrapingbee.com/blog/web-scraping-go/
- https://zenrows.com/blog/go-web-scraping
- https://stackoverflow.com/questions/tagged/go+html-parsing
- https://go.dev/doc/articles/html_parser