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

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

このコミットは、Go言語のhtmlパッケージにおけるHTMLパーサーの挙動を修正するものです。具体的には、<head>要素内に存在する空白文字のテキストノードの扱いを改善し、HTML5の仕様に準拠させることを目的としています。これにより、特定のHTML構造を持つドキュメントが正しくパースされるようになります。

コミット

commit 053549ca1bd77aeaff45ddb574a9f5593962e0d5
Author: Andrew Balholm <andybalholm@gmail.com>
Date:   Fri Oct 28 09:06:30 2011 +1100

    html: allow whitespace text nodes in <head>
    
    Pass tests1.dat, test 50:
    <!DOCTYPE html><script> <!-- </script> --> </script> EOF
    
    | <!DOCTYPE html>
    | <html>
    |   <head>
    |     <script>
    |       " <!-- "
    |     " "
    |   <body>
    |     "-->  EOF"
    
    Also pass tests through test 54:
    <!DOCTYPE html><title>U-test</title><body><div><p>Test<u></p></div></body>
    
    R=nigeltao
    CC=golang-dev
    https://golang.org/cl/5311066

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

https://github.com/golang/go/commit/053549ca1bd77aeaff45ddb574a9f5593962e0d5

元コミット内容

このコミットの目的は、「htmlパッケージが<head>内の空白テキストノードを許可するようにする」ことです。これにより、tests1.datのテスト50、およびテスト54までの他のテストがパスするようになります。

テスト50の具体的な入力と期待されるパース結果が示されています。 入力: <!DOCTYPE html><script> <!-- </script> --> </script> EOF 期待されるDOM構造(簡略化された表現):

<!DOCTYPE html>
<html>
  <head>
    <script>
      " <!-- "
    " "
  <body>
    "-->  EOF"

このテストケースは、<script>タグ内にコメントのように見える文字列と、その後に続く空白文字が存在する場合に、それらがどのようにパースされるかを示しています。特に、<head>要素内で空白文字がテキストノードとして適切に扱われることが重要です。

また、この変更により、テスト54までの他のテストもパスするようになったと述べられています。これは、この修正がより広範なHTMLパースの正確性向上に寄与することを示唆しています。

レビュー担当者(R=)はnigeltao、CC(カーボンコピー)はgolang-dev、関連するGo CL(Change List)のリンクはhttps://golang.org/cl/5311066です。

変更の背景

HTML5のパースアルゴリズムは非常に複雑であり、ブラウザ間の互換性を確保するために厳密に定義されています。特に、要素間の空白文字(inter-element whitespace)の扱いは、DOMツリーの構築に影響を与える重要な側面です。

このコミットが行われた背景には、Go言語のhtmlパッケージがHTML5の仕様に完全に準拠していなかったという問題があります。具体的には、<head>要素内において、空白文字のみで構成されるテキストノードが正しく処理されず、パース結果がHTML5の期待するDOM構造と異なってしまうケースが存在しました。

上記の「元コミット内容」で示されているテスト50は、この問題を示す典型的な例です。このテストケースでは、<head>内に<script>タグがあり、その内部にコメントのように見える文字列と空白文字が含まれています。HTML5の仕様では、このような空白文字もテキストノードとしてDOMに含める必要があります。しかし、修正前のパーサーは、<head>内のTextToken(テキストノードに対応するトークン)を適切に処理せず、空白文字を無視してしまうか、誤った方法で処理していたと考えられます。

この不正確なパースは、HTMLドキュメントのレンダリングやJavaScriptによるDOM操作に予期せぬ影響を与える可能性がありました。そのため、HTML5仕様への準拠を強化し、より堅牢なHTMLパーサーを提供するために、この修正が必要とされました。

前提知識の解説

HTML5パースアルゴリズム

HTML5のパースアルゴリズムは、WHATWG (Web Hypertext Application Technology Working Group) によって厳密に定義されており、すべてのWebブラウザがこの仕様に従ってHTMLドキュメントを解析することを目的としています。このアルゴリズムは、大きく分けて以下の2つのフェーズで構成されます。

  1. トークン化 (Tokenization): 入力されたHTML文字列を、意味のある単位である「トークン」に分解するプロセスです。トークンには、開始タグ、終了タグ、テキスト、コメント、DOCTYPEなどが含まれます。このフェーズは、ステートマシンとして動作し、現在の状態と入力文字に基づいて次の状態と出力トークンを決定します。

  2. ツリー構築 (Tree Construction): トークン化フェーズで生成されたトークンストリームを受け取り、それらを基にDOM (Document Object Model) ツリーを構築するプロセスです。このフェーズもステートマシンとして動作し、「挿入モード (Insertion Mode)」と呼ばれる概念が中心となります。挿入モードは、現在のパーサーの状態(例: <head>内、<body>内など)に応じて、トークンがどのようにDOMツリーに挿入されるかを決定します。

挿入モード (Insertion Mode)

挿入モードは、HTMLパーサーが現在どのHTML要素のコンテキストで動作しているかを示す状態です。各挿入モードには、特定のトークンが検出されたときに実行すべき一連のルールが定義されています。例えば、<head>要素の内部をパースしている間は「in head」挿入モードになり、このモードでは<title>, <link>, <meta>, <script>, <style>などの要素が期待されます。

<head>要素内の空白文字の扱い

HTML5の仕様では、<head>要素内の空白文字(スペース、タブ、改行、フォームフィードなど)は、通常、テキストノードとしてDOMツリーに含められます。これは、たとえその空白文字が視覚的な意味を持たなくても、DOMの構造の一部として扱われるべきであるという考えに基づいています。

Web検索の結果からもわかるように、HTML5のパースアルゴリズムは<head>要素内の空白文字を特定のルールに従って処理します。

  • <head>要素の直前のASCII空白文字はパース時に削除されます。
  • 要素間の空白文字は一般的に許可され、DOMではテキストノードとして表現されます。
  • 空のテキストノードや空白のみを含むテキストノードは「要素間空白 (inter-element whitespace)」と見なされます。
  • 要素間空白、コメントノード、処理命令ノードは、要素のコンテンツモデルがその内容と一致するかどうかを判断する際や、ドキュメントおよび要素のセマンティクスを定義するアルゴリズムに従う際には無視されます。

しかし、これはDOMツリーに存在しないという意味ではなく、特定のセマンティックな処理において無視されるということを意味します。DOMツリー自体には、これらの空白文字がテキストノードとして存在することが期待されます。

Go言語のhtmlパッケージ

Go言語の標準ライブラリには、HTML5のパースアルゴリズムを実装したhtmlパッケージ(golang.org/x/net/htmlの前身)が含まれています。このパッケージは、HTMLドキュメントを解析し、DOMツリーを構築するための機能を提供します。このコミットは、そのパーサーの内部実装、特に<head>要素内のテキストノード処理に関する修正です。

技術的詳細

このコミットの技術的詳細は、Go言語のhtmlパッケージにおけるHTMLパーサーのinHeadIM("in head insertion mode")関数の挙動変更に集約されます。

修正前のinHeadIM関数は、TextToken(テキストノードを表すトークン)が検出された際に、その内容を無条件にimplied = trueとして処理していました。これは、テキストノードが暗黙的に生成されたものとして扱われ、その内容が適切にDOMツリーに追加されない、または空白文字が無視される原因となっていました。

具体的には、switch p.tok.Type文の中で、TextTokenErrorTokenが同じケースで処理されていました。

	switch p.tok.Type {
	case ErrorToken, TextToken: // 修正前
		implied = true
	// ...
	}

この修正では、TextTokenの処理がErrorTokenから分離され、より詳細な空白文字の処理ロジックが追加されました。

  1. stringsパッケージのインポート: 空白文字のトリミング処理のために、"strings"パッケージが新しくインポートされました。

  2. whitespace定数の定義: 空白文字を定義する定数whitespaceが追加されました。これは、スペース、タブ、キャリッジリターン、改行、フォームフィードを含みます。

    const whitespace = " \t\r\n\f"
    
  3. TextTokenの処理ロジックの変更: inHeadIM関数内の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 inHeadIM, true // 現在の挿入モードを維持し、暗黙的な処理として終了
    			}
    			p.tok.Data = s // 残りの非空白部分をトークンデータとして設定
    		}
    		implied = true // 暗黙的な処理としてマーク
    

    この新しいロジックは、TextTokenのデータ(p.tok.Data)の左側から空白文字をトリムします。

    • もし空白文字がトリムされた場合(つまり、元のトークンデータに先頭空白が含まれていた場合)、そのトリムされた空白部分が新しいテキストノードとして現在のノードに追加されます(p.addText(...))。
    • もしトークンデータ全体が空白文字であった場合(s == "")、それ以上の処理は不要なため、inHeadIMモードを維持し、trueを返して終了します。
    • 空白文字がトリムされた後、残りの非空白部分が新しいp.tok.Dataとして設定されます。これにより、パーサーは残りの非空白部分を後続の処理で適切に扱うことができます。

この変更により、<head>要素内で検出されたTextTokenが空白文字を含んでいた場合でも、その空白文字がDOMツリーにテキストノードとして正確に反映されるようになります。これは、HTML5の仕様で定義されている<head>内の要素間空白の扱いに準拠するための重要な修正です。

また、inHeadIM関数の最後のreturn文も変更されています。

	return inHeadIM, true // 修正後

修正前はreturn inHeadIM, !impliedでしたが、TextTokenの処理がimplied = trueに設定されるようになったため、常にtrueを返すように変更されました。これは、<head>内のテキストノードの処理が常に暗黙的な挿入として扱われることを意味します。

テストファイルsrc/pkg/html/parse_test.goでは、TestParser関数内のテストループの範囲がi < 50からi < 55に拡張されました。これは、この修正によってテスト50だけでなく、テスト54までの他のテストもパスするようになったことを反映しています。

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

diff --git a/src/pkg/html/parse.go b/src/pkg/html/parse.go
index 276f0b7fbf..fdd6f75aab 100644
--- a/src/pkg/html/parse.go
+++ b/src/pkg/html/parse.go
@@ -7,6 +7,7 @@ package html
 import (
 	"io"
 	"os"
+	"strings"
 )
 
 // A parser implements the HTML5 parsing algorithm:
@@ -430,6 +431,8 @@ func beforeHeadIM(p *parser) (insertionMode, bool) {
 	return inHeadIM, !implied
 }
 
+const whitespace = " \t\r\n\f"
+
 // Section 11.2.5.4.4.
 func inHeadIM(p *parser) (insertionMode, bool) {
 	var (
@@ -437,7 +440,18 @@ func inHeadIM(p *parser) (insertionMode, bool) {
 		implied bool
 	)
 	switch p.tok.Type {
-\tcase ErrorToken, TextToken:
+\tcase ErrorToken:
+\t\timplied = true
+\tcase TextToken:
+\t\ts := strings.TrimLeft(p.tok.Data, whitespace)
+\t\tif len(s) < len(p.tok.Data) {
+\t\t\t// Add the initial whitespace to the current node.
+\t\t\tp.addText(p.tok.Data[:len(p.tok.Data)-len(s)])
+\t\t\tif s == "" {
+\t\t\t\treturn inHeadIM, true
+\t\t\t}\n+\t\t\tp.tok.Data = s
+\t\t}
 \t\timplied = true
 \tcase StartTagToken:
 \t\tswitch p.tok.Data {
@@ -469,7 +483,7 @@ func inHeadIM(p *parser) (insertionMode, bool) {
 \t\t}\n \t\treturn afterHeadIM, !implied
 \t}\n-\treturn inHeadIM, !implied
+\treturn inHeadIM, true
 }\n \n // Section 11.2.5.4.6.
diff --git a/src/pkg/html/parse_test.go b/src/pkg/html/parse_test.go
index 86f1298d5e..ae4ecd6658 100644
--- 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.
-\t\tfor i := 0; i < 50; i++ {
+\t\tfor i := 0; i < 55; i++ {
 			// Parse the #data section.
 			b, err := ioutil.ReadAll(<-rc)
 			if err != nil {

コアとなるコードの解説

src/pkg/html/parse.go

  1. import "strings"の追加: stringsパッケージは、文字列操作のためのユーティリティ関数を提供します。ここでは、テキストノードから空白文字をトリムするために使用されます。

  2. const whitespace = " \t\r\n\f"の追加: HTML5の仕様で定義されている空白文字(スペース、タブ、キャリッジリターン、改行、フォームフィード)をまとめた定数です。これにより、コードの可読性が向上し、将来的な変更も容易になります。

  3. inHeadIM関数のTextToken処理の変更: inHeadIM関数は、HTMLパーサーが<head>要素の内部を処理している際の挙動を定義します。

    • 修正前: case ErrorToken, TextToken:として、エラーとテキストトークンを同じように処理していました。これは、<head>内のテキストノード(特に空白のみのノード)が適切にDOMツリーに追加されない原因となっていました。
    • 修正後:
      • ErrorTokenTextTokenのケースが分離されました。
      • TextTokenの処理において、strings.TrimLeft(p.tok.Data, whitespace)を使用して、トークンデータの先頭から空白文字を削除します。
      • もし空白文字が削除された場合(len(s) < len(p.tok.Data))、削除された空白部分(p.tok.Data[:len(p.tok.Data)-len(s)])をp.addText()メソッドを使って現在のノードにテキストノードとして追加します。これにより、<head>内の空白文字がDOMツリーに正しく反映されるようになります。
      • もしトークンデータ全体が空白文字であった場合(s == "")、それ以上の処理は不要なため、return inHeadIM, trueで現在の挿入モードを維持し、処理を終了します。
      • 空白文字が削除された後、残りの非空白部分がp.tok.Dataに再割り当てされます。これにより、パーサーは残りのデータを適切に処理できます。
      • 最終的にimplied = trueが設定され、このテキストノードの挿入が暗黙的なものであることを示します。
  4. inHeadIM関数の最後のreturn文の変更:

    • 修正前: return inHeadIM, !implied
    • 修正後: return inHeadIM, true この変更は、TextTokenの処理が常にimplied = trueとなるように変更されたことに伴うものです。これにより、<head>内のテキストノードの処理が常に暗黙的な挿入として扱われるという一貫性が保たれます。

src/pkg/html/parse_test.go

  1. TestParser関数のテストループ範囲の変更: TestParser関数は、tests1.datファイルからテストケースを読み込み、パーサーの挙動を検証します。
    • 修正前: for i := 0; i < 50; i++として、テスト50までを実行していました。
    • 修正後: for i := 0; i < 55; i++として、テスト55までを実行するように変更されました。 この変更は、今回の修正によってテスト50だけでなく、テスト54までの他のテストも正しくパスするようになったことを反映しており、パーサーの改善がより広範なテストケースに適用されたことを示しています。

これらの変更により、Go言語のhtmlパーサーは、HTML5の仕様にさらに厳密に準拠し、特に<head>要素内の空白文字のテキストノードを正確に処理できるようになりました。

関連リンク

  • Go CL (Change List): https://golang.org/cl/5311066

参考にした情報源リンク