[インデックス 10989] ファイルの概要
このコミットは、Go言語のhtml
パッケージにおけるHTMLパーサーのバグ修正に関するものです。具体的には、HTML5パーシングアルゴリズムの「after after frameset」モードにおいて、空白文字(スペース、タブ、改行など)が誤って無視されていた問題を修正し、仕様通りにテキストノードとして扱われるように変更しました。これにより、特定のHTML構造(特にframeset
要素を含むもの)が正しくパースされるようになります。
コミット
- Author: Andrew Balholm andybalholm@gmail.com
- Date: Fri Dec 23 11:07:11 2011 +1100
- Commit Hash: 4a8ea4ae94c5db39f38cd1c8b7d0c8df6dc82f7b
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/4a8ea4ae94c5db39f38cd1c8b7d0c8df6dc82f7b
元コミット内容
html: Don't ignore whitespace in "after after frameset" mode.
Pass tests6.dat, test 46:
<html><frameset></frameset></html>
| <html>
| <head>
| <frameset>
| " "
R=nigeltao
CC=golang-dev
https://golang.org/cl/5505065
変更の背景
HTML5の仕様では、ウェブページの構造を解析するための厳密なパーシングアルゴリズムが定義されています。このアルゴリズムは、入力されたHTMLをトークン化し、それらのトークンに基づいてDOM(Document Object Model)ツリーを構築する際に、現在のパーサーの状態(インサーションモード)に応じて異なる挙動を取ります。
このコミットが修正する問題は、特にframeset
要素が閉じられた後の特定のパーシングモードである「after after frameset」モードにおいて発生していました。このモードでは、HTML5の仕様上、空白文字(スペース、タブ、改行、フォームフィード、キャリッジリターン)はテキストノードとしてDOMツリーに追加されるべきです。しかし、Go言語のhtml
パッケージのパーサーは、このモードで空白文字を含むすべてのテキストトークンを誤って無視していました。
このバグにより、<html><frameset></frameset></html>
のような、frameset
の後に空白文字が続くようなHTML構造が正しくパースされず、DOMツリーに空白文字が反映されないという問題がありました。これは、ブラウザのレンダリング結果や、DOMツリーを操作するJavaScriptの挙動に影響を与える可能性がありました。このコミットは、tests6.dat
のテスト46(修正後はテスト47)がこの問題を示していたため、そのテストをパスするように修正されました。
前提知識の解説
HTMLパーシングの基本
HTMLパーシングは、ブラウザがHTMLドキュメントを読み込み、それを表示可能なウェブページに変換するプロセスです。このプロセスは大きく分けて以下のステップで行われます。
- バイトストリームから文字へのデコード: HTMLドキュメントのバイト列を文字に変換します。
- トークン化: 文字列を意味のある単位(トークン)に分割します。例えば、
<p>
は開始タグトークン、Hello
は文字トークン、</p>
は終了タグトークンです。 - ツリー構築: トークンストリームをDOMツリーに変換します。この際、HTML5のパーシングアルゴリズムに従って、要素の親子関係やテキストノードの挿入が行われます。
HTML5パーシングアルゴリズムとインサーションモード
HTML5のパーシングアルゴリズムは、非常に複雑なステートマシンとして定義されています。これは、不完全なHTMLや不正なHTMLであっても、一貫した方法でDOMツリーを構築できるようにするためです。パーサーは常に特定の「インサーションモード」にあり、このモードが次にどのトークンをどのように処理するかを決定します。
主要なインサーションモードには以下のようなものがあります。
- "initial": ドキュメントの開始時。
- "before html":
<html>
タグの前にいる状態。 - "in head":
<head>
タグの中にいる状態。 - "in body":
<body>
タグの中にいる状態。 - "in frameset":
<frameset>
タグの中にいる状態。 - "after frameset":
</frameset>
タグの直後にいる状態。 - "after after frameset":
after frameset
モードの後に、さらに特定の条件を満たした場合に遷移する状態。このモードは、frameset
要素が閉じられた後、ドキュメントの残りの部分を処理する際に使用されます。
各モードでは、受信したトークンの種類(開始タグ、終了タグ、テキスト、コメントなど)に応じて、DOMツリーへのノードの追加、モードの変更、エラー処理など、異なるアクションが定義されています。
frameset
要素の役割とHTMLにおける特殊性
<frameset>
要素は、HTML4までで使われていた、ブラウザウィンドウを複数のフレームに分割するための要素です。HTML5では非推奨となり、代わりにCSSや<iframe>
要素が推奨されています。しかし、古いHTMLドキュメントとの互換性のために、HTML5パーシングアルゴリズムはframeset
要素の処理方法を定義しています。
frameset
要素は、その性質上、通常のHTML要素とは異なるパーシングルールを持ちます。例えば、<body>
要素と同時に存在することはできません。そのため、frameset
関連のモードは、パーシングアルゴリズムにおいて特別な扱いを受けます。
Go言語のhtml
パッケージ
Go言語の標準ライブラリには、HTML5のパーシングアルゴリズムを実装したhtml
パッケージ(src/pkg/html
)が含まれています。このパッケージは、HTMLドキュメントを解析し、DOMツリーを構築するための機能を提供します。ウェブスクレイピング、HTMLテンプレート処理、HTMLのサニタイズなど、様々な用途で利用されます。
技術的詳細
「after after frameset」モードの挙動
HTML5の仕様(https://html.spec.whatwg.org/multipage/parsing.html#the-after-after-frameset-insertion-mode)によると、「after after frameset」インサーションモードでは、以下のようなルールが適用されます。
- テキストトークン:
- ASCII空白文字(スペース、タブ、改行、フォームフィード、キャリッジリターン)の場合、現在のノードにテキストノードとして追加されます。
- それ以外の文字の場合、パースエラーとなり、その文字は無視されます。
- コメントトークン: コメントノードとしてDOMツリーに追加されます。
- DOCTYPEトークン: パースエラーとなり、無視されます。
- 開始タグトークン:
html
タグの場合、in body
モードに切り替わり、html
要素の開始タグを処理します。- それ以外のタグの場合、パースエラーとなり、無視されます。
- 終了タグトークン: パースエラーとなり、無視されます。
- EOFトークン: ドキュメントの解析を終了します。
このコミット以前のGoのパーサーは、このモードでテキストトークンを受け取った際に、空白文字であるかどうかにかかわらず、すべてのテキストを無視していました。これは仕様に反する挙動でした。
strings.Map
関数の利用
修正されたコードでは、Go言語のstrings.Map
関数が使用されています。この関数は、文字列の各ルーン(Unicodeコードポイント)に対して指定された関数を適用し、その結果として新しい文字列を構築します。
s := strings.Map(func(c rune) rune {
switch c {
case ' ', '\t', '\n', '\f', '\r':
return c // 空白文字はそのまま返す
}
return -1 // 空白文字以外は-1を返し、結果の文字列から除外する
}, p.tok.Data)
このコードスニペットは、入力されたテキストトークン(p.tok.Data
)から、ASCII空白文字のみを抽出し、それ以外の文字をすべて破棄する役割を果たします。strings.Map
のコールバック関数が-1
を返すと、そのルーンは結果の文字列に含まれません。これにより、仕様で求められている「空白文字のみをテキストノードとして追加する」という挙動が実現されます。
reconstructActiveFormattingElements
とaddText
p.reconstructActiveFormattingElements()
: この関数は、HTMLパーシングアルゴリズムにおける「アクティブなフォーマット要素のリスト」を再構築するために呼び出されます。これは、<b>
や<em>
などのフォーマット要素が正しくネストされ、DOMツリーに反映されるようにするために重要です。テキストノードを追加する前にこれを呼び出すことで、テキストが正しいフォーマットコンテキストに挿入されることが保証されます。p.addText(s)
: この関数は、抽出された空白文字s
を現在のノードの子としてテキストノードとしてDOMツリーに追加します。
コアとなるコードの変更箇所
変更は主に以下の2つのファイルで行われました。
src/pkg/html/parse.go
: HTMLパーサーの主要なロジックが含まれるファイル。src/pkg/html/parse_test.go
: パーサーのテストケースが含まれるファイル。
src/pkg/html/parse.go
の変更
--- a/src/pkg/html/parse.go
+++ b/src/pkg/html/parse.go
@@ -1572,6 +1572,19 @@ func afterAfterFramesetIM(p *parser) bool {
Type: CommentNode,
Data: p.tok.Data,
})
+ case TextToken:
+ // Ignore all text but whitespace.
+ s := strings.Map(func(c rune) rune {
+ switch c {
+ case ' ', '\t', '\n', '\f', '\r':
+ return c
+ }
+ return -1
+ }, p.tok.Data)
+ if s != "" {
+ p.reconstructActiveFormattingElements()
+ p.addText(s)
+ }
case StartTagToken:
switch p.tok.Data {
case "html":
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", 45},
+ {"tests6.dat", 47},
{"tests10.dat", 16},
}
for _, tf := range testFiles {
コアとなるコードの解説
src/pkg/html/parse.go
の変更点
afterAfterFramesetIM
関数は、「after after frameset」インサーションモードにおけるパーサーの挙動を定義しています。このコミットでは、TextToken
(テキストトークン)が検出された場合の処理が追加されました。
case TextToken:
の追加:- 以前は、このモードでテキストトークンが来ると、デフォルトのケース(おそらく無視されるか、エラーとして扱われる)にフォールスルーしていました。
- 新しいコードでは、まず
strings.Map
を使って、入力されたテキストトークン(p.tok.Data
)から空白文字(スペース、タブ、改行、フォームフィード、キャリッジリターン)のみを抽出します。それ以外の文字はすべて破棄されます。 - 抽出された空白文字の文字列
s
が空でない場合(つまり、テキストトークンに少なくとも1つの空白文字が含まれていた場合)、以下の処理が行われます。p.reconstructActiveFormattingElements()
: アクティブなフォーマット要素のリストを再構築します。これは、テキストが正しいコンテキストでDOMツリーに追加されることを保証するために重要です。p.addText(s)
: 抽出された空白文字s
をテキストノードとして現在のノードの子に追加します。
この変更により、「after after frameset」モードで空白文字が正しくDOMツリーに挿入されるようになり、HTML5の仕様に準拠したパーシングが可能になりました。
src/pkg/html/parse_test.go
の変更点
tests6.dat
の期待値の変更:{"tests6.dat", 45}
から{"tests6.dat", 47}
へと変更されました。- これは、
tests6.dat
というテストデータファイルにおいて、以前はテスト45までがパスすれば良いとされていたものが、今回の修正によってテスト47までパスするようになったことを示しています。具体的には、テスト46(修正後のテスト47)が、この空白文字のパースに関するバグを検出するためのテストケースであり、今回の修正によってそのテストがパスするようになったことを意味します。
関連リンク
- Go CL (Code Review) リンク: https://golang.org/cl/5505065
参考にした情報源リンク
- HTML Standard - 13.2.6.4.17 The "after after frameset" insertion mode: https://html.spec.whatwg.org/multipage/parsing.html#the-after-after-frameset-insertion-mode
- GoDoc - strings.Map: https://pkg.go.dev/strings#Map
- HTML5 Parsing Algorithm Visualizer (参考): https://htmlparser.info/ (直接的な情報源ではないが、パーシングアルゴリズムの理解に役立つ)