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

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

このコミットは、Go言語の実験的なHTMLパーサー(exp/htmlパッケージ)におけるテーブル要素内のパース挙動を、HTML5仕様に準拠させるための調整です。具体的には、inTableIM("in table" insertion mode)関数が変更され、空白のみのテキストノードの処理、特定の要素(<style>, <script>, <input>, <form>)のハンドリング、DOCTYPEトークンの無視、そしてparseImpliedTokenの利用方法が改善されています。これにより、HTML5の複雑なテーブルパース規則への適合性が向上し、20の追加テストケースがパスするようになりました。

コミット

commit dde8358a1c504f15a1c17ee0822622ea172f1f3d
Author: Andrew Balholm <andybalholm@gmail.com>
Date:   Wed Apr 25 10:49:27 2012 +1000

    exp/html: adjust inTableIM to match spec
    
    Don't foster-parent text nodes that consist only of whitespace.
    (I implemented this entirely in inTableIM instead of creating an
    inTableTextIM, because the sole purpose of inTableTextIM seems to be
    to combine character tokens into a string, which our tokenizer does
    already.)
    
    Use parseImpliedToken to clarify a couple of cases.
    
    Handle <style>, <script>, <input>, and <form>.
    
    Ignore doctype tokens.
    
    Pass 20 additional tests.
    
    R=nigeltao
    CC=golang-dev
    https://golang.org/cl/6117048

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

https://github.com/golang/go/commit/dde8358a1c504f15a1c17ee0822622ea172f1f3d

元コミット内容

exp/html: adjust inTableIM to match spec

空白のみで構成されるテキストノードをフォスターペアレントしないように調整。 (この実装は、inTableTextIMを新たに作成する代わりに、inTableIM内で完結させた。なぜなら、inTableTextIMの唯一の目的は文字トークンを文字列に結合することであり、それは既存のトークナイザーが既に行っているためである。)

いくつかのケースを明確にするためにparseImpliedTokenを使用。

<style>, <script>, <input>, <form>要素を処理。

DOCTYPEトークンを無視。

20の追加テストをパス。

変更の背景

HTML5の仕様は、ウェブブラウザがHTMLドキュメントをどのようにパースし、DOMツリーを構築するかについて非常に詳細な規則を定めています。特にテーブル要素(<table>)の内部は、その構造の複雑さから、パース規則が非常に厳格かつ特殊です。例えば、テーブルの内部に本来配置されるべきではない要素(例えば、<div>や直接のテキストノード)が出現した場合、ブラウザはエラーとして処理するのではなく、仕様に基づいてそれらの要素をDOMツリーの別の場所(通常はテーブルの直前)に「フォスターペアレント(foster-parenting)」するという挙動が定義されています。

このコミットが行われた当時、Go言語のexp/htmlパッケージはまだ実験段階であり、HTML5のパース仕様に完全に準拠しているわけではありませんでした。特に、テーブル内部での要素の挿入モード(inTableIM)の挙動が仕様と異なっている点が問題となっていました。具体的には、空白のみのテキストノードが不適切にフォスターペアレントされたり、<style>, <script>, <input>, <form>といった特定の要素がテーブル内で正しく処理されない、あるいはDOCTYPEトークンが誤って扱われるなどの不整合がありました。

これらの不整合は、Go言語のHTMLパーサーが生成するDOMツリーが、他のブラウザやHTMLバリデーターが生成するものと異なる可能性を意味し、互換性の問題や予期せぬレンダリング結果を引き起こす可能性がありました。このコミットは、これらの問題を修正し、exp/htmlパーサーのHTML5仕様への準拠度を高めることを目的としています。これにより、より堅牢で互換性のあるHTMLパースが実現されます。

前提知識の解説

このコミットを理解するためには、以下のHTML5パースに関する基本的な概念を理解しておく必要があります。

  1. HTML5パースアルゴリズム: HTML5のパースは、トークナイゼーションとツリー構築の2つの主要なフェーズに分かれています。

    • トークナイゼーション: 入力されたHTML文字列を、タグ、テキスト、コメントなどの「トークン」に分解します。
    • ツリー構築: トークナイザーから受け取ったトークンに基づいて、DOMツリーを構築します。このフェーズは、現在の「挿入モード(insertion mode)」に基づいて動作します。
  2. 挿入モード(Insertion Mode): HTML5のツリー構築アルゴリズムの中心的な概念です。パーサーは常に特定の挿入モードにあり、このモードが次に受け取るトークンをどのように処理するかを決定します。例えば、<head>タグの中では「in head」モード、<body>タグの中では「in body」モード、<table>タグの中では「in table」モードなどがあります。各モードには、特定のトークンが来た場合の詳細な処理規則が定義されています。

  3. inTableIM(In Table Insertion Mode): テーブル要素(<table>)の内部でパーサーが動作する際の挿入モードです。HTMLのテーブル構造は非常に厳格であり、<table>の直下には<caption>, <colgroup>, <thead>, <tbody>, <tfoot>のいずれか、またはスクリプト要素やコメント、空白文字のみのテキストノードしか配置できません。それ以外の要素が来た場合、HTML5仕様では特別なエラー処理や要素の再配置(フォスターペアレント)が定義されています。

  4. フォスターペアレント(Foster-parenting): HTML5パースアルゴリズムにおける特殊なエラー処理メカニズムの一つです。テーブル要素の内部に、テーブルのコンテンツモデルに適合しない要素(例えば、<div>や直接のテキストノード)が誤って配置された場合、ブラウザはその要素をエラーとして破棄するのではなく、テーブルの直前(または他の適切な場所)に移動させてDOMツリーに挿入します。これにより、不正なHTMLでも可能な限りDOMツリーが構築され、コンテンツが表示されるようになります。

  5. parseImpliedToken: HTML5パースにおいて、特定の状況下でパーサーが明示的なタグトークンを受け取っていないにもかかわらず、あたかもそのタグが存在したかのようにDOMツリーに要素を挿入するメカニズムです。例えば、<table><tr><td>...というHTMLがあった場合、<tr>の前に<tbody>が明示的に書かれていなくても、パーサーは自動的に<tbody>要素を挿入します。これは、HTMLの構造的な整合性を保つために行われます。

  6. 空白文字のみのテキストノード: HTMLドキュメント内の改行、スペース、タブなどの空白文字のみで構成されるテキストノードです。これらのノードは、レンダリングには影響しないことが多いですが、DOMツリーの構造には影響を与えます。HTML5のパース仕様では、これらの空白ノードがテーブル内でどのように扱われるかについても詳細な規則があります。

これらの概念を理解することで、コミットがなぜ、どのようにHTMLパーサーの挙動を修正しているのかが明確になります。

技術的詳細

このコミットの技術的詳細は、主にsrc/pkg/exp/html/parse.goファイルのinTableIM関数の変更に集約されています。

  1. 空白のみのテキストノードのフォスターペアレントの抑制:

    • 変更前は、テーブル内部でテキストトークン(TextToken)が検出された場合、// TODO.とコメントされており、適切な処理が実装されていませんでした。
    • 変更後、TextTokenが検出された際に、まずトークンのデータからヌル文字(\x00)が削除されます。
    • 次に、現在の要素スタックの最上位要素(p.oe.top())がtable, tbody, tfoot, thead, trのいずれかである場合、かつ、そのテキストトークンが空白文字のみで構成されている(strings.Trim(p.tok.Data, whitespace) == "")場合、そのテキストノードはフォスターペアレントされずに、単にp.addText(p.tok.Data)によって現在の要素に追加されます。そして、return trueで処理を終了します。
    • これにより、HTML5仕様で定義されているように、テーブル内部の空白のみのテキストノードは、フォスターペアレントされずにテーブルのコンテンツとして扱われるようになります。
  2. parseImpliedTokenの利用による構造の明確化:

    • 変更前は、<td>, <th>, <tr>タグがinTableIMで検出された際に、p.clearStackToContext(tableScope)p.addElement("tbody", nil)を直接呼び出し、その後inTableBodyIMにモードを切り替えていました。これは、<tr><td><tbody>の子要素として暗黙的に扱われるべきであるというHTML5の規則に対応するためのものでした。
    • 変更後、この処理がp.parseImpliedToken(StartTagToken, "tbody", nil)に置き換えられました。parseImpliedTokenを使用することで、パーサーはあたかも<tbody>の開始タグが検出されたかのように内部的に処理を行い、要素スタックに<tbody>を追加します。これにより、コードの意図がより明確になり、HTML5の暗黙的なタグ挿入のセマンティクスに直接対応するようになりました。同様に、<col>タグの処理においても、parseImpliedToken(StartTagToken, "colgroup", nil)が使用されています。
  3. 特定の要素(<style>, <script>, <input>, <form>)のハンドリング:

    • <style><script>: これらのタグがinTableIMで検出された場合、return inHeadIM(p)が追加されました。これは、HTML5仕様において、テーブル内部に<style><script>が出現した場合、それらはテーブルのコンテンツとしてではなく、あたかも<head>要素内に存在するかのように処理されるべきであるという規則に対応しています。これにより、これらの要素はDOMツリーの適切な場所に配置されます。
    • <input>: <input>タグが検出された場合、そのtype属性がhiddenであるかどうかをチェックします。もしtype="hidden"であれば、その要素は追加され(p.addElement)、直後に要素スタックからポップされます(p.oe.pop())。これは、隠し入力フィールドがDOMツリーに一時的に追加された後、すぐに削除されるというHTML5の特殊な処理に対応しています。それ以外のinputタイプの場合は、デフォルトの動作にフォールバックします。
    • <form>: <form>タグが検出された場合、パーサーのformフィールドが既に設定されている(つまり、既にアクティブなフォーム要素が存在する)場合、そのトークンは無視されます。これは、HTML5においてフォーム要素のネストが許可されていないため、二重にフォームが開始されるのを防ぐための処理です。formフィールドがnilの場合は、新しいフォーム要素が追加され、p.formに設定されます。
  4. DOCTYPEトークンの無視:

    • DoctypeTokeninTableIMで検出された場合、// Ignore the token.というコメントと共にreturn trueが追加されました。これは、テーブル内部でDOCTYPE宣言が出現することはHTMLとして不正であり、パーサーはこれを無視すべきであるというHTML5の規則に対応しています。

これらの変更により、exp/htmlパーサーはHTML5の複雑なテーブルパース規則、特にエラー回復メカニズムや暗黙的なタグ挿入の挙動に関して、より正確に準拠するようになりました。

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

変更は主に src/pkg/exp/html/parse.go ファイルの inTableIM 関数に集中しています。

--- a/src/pkg/exp/html/parse.go
+++ b/src/pkg/exp/html/parse.go
@@ -1139,7 +1139,14 @@ func inTableIM(p *parser) bool {
 		// Stop parsing.
 		return true
 	case TextToken:
-		// TODO.
+		p.tok.Data = strings.Replace(p.tok.Data, "\x00", "", -1)
+		switch p.oe.top().Data {
+		case "table", "tbody", "tfoot", "thead", "tr":
+			if strings.Trim(p.tok.Data, whitespace) == "" {
+				p.addText(p.tok.Data)
+				return true
+			}
+		}
 	case StartTagToken:
 		switch p.tok.Data {
 		case "caption":
@@ -1148,15 +1155,21 @@ func inTableIM(p *parser) bool {
 			p.addElement(p.tok.Data, p.tok.Attr)
 			p.im = inCaptionIM
 			return true
+		case "colgroup":
+			p.clearStackToContext(tableScope)
+			p.addElement(p.tok.Data, p.tok.Attr)
+			p.im = inColumnGroupIM
+			return true
+		case "col":
+			p.parseImpliedToken(StartTagToken, "colgroup", nil)
+			return false
 		case "tbody", "tfoot", "thead":
 			p.clearStackToContext(tableScope)
 			p.addElement(p.tok.Data, p.tok.Attr)
 			p.im = inTableBodyIM
 			return true
 		case "td", "th", "tr":
-			p.clearStackToContext(tableScope)
-			p.addElement("tbody", nil)
-			p.im = inTableBodyIM
+			p.parseImpliedToken(StartTagToken, "tbody", nil)
 			return false
 		case "table":
 			if p.popUntil(tableScope, "table") {
@@ -1165,16 +1178,24 @@ func inTableIM(p *parser) bool {
 			}
 			// Ignore the token.
 			return true
-		case "colgroup":
-			p.clearStackToContext(tableScope)
+		case "style", "script":
+			return inHeadIM(p)
+		case "input":
+			for _, a := range p.tok.Attr {
+				if a.Key == "type" && strings.ToLower(a.Val) == "hidden" {
+					p.addElement(p.tok.Data, p.tok.Attr)
+					p.oe.pop()
+					return true
+				}
+			}
+			// Otherwise drop down to the default action.
+		case "form":
+			if p.form != nil {
+				// Ignore the token.
+				return true
+			}
 			p.addElement(p.tok.Data, p.tok.Attr)
-			p.im = inColumnGroupIM
-			return true
-		case "col":
-			p.clearStackToContext(tableScope)
-			p.addElement("colgroup", p.tok.Attr)
-			p.im = inColumnGroupIM
-			return false
+			p.form = p.oe.pop()
 		case "select":
 			p.reconstructActiveFormattingElements()
 			switch p.top().Data {
@@ -1186,8 +1207,6 @@ func inTableIM(p *parser) bool {
 			p.framesetOK = false
 			p.im = inSelectInTableIM
 			return true
-		default:\n-\t\t\t// TODO.\n 		}
 	case EndTagToken:
 	\tswitch p.tok.Data {
@@ -1208,6 +1227,9 @@ func inTableIM(p *parser) bool {
 			Data: p.tok.Data,
 		})\n 		return true
+	case DoctypeToken:
+		// Ignore the token.
+		return true
 	}
 
 	switch p.top().Data {

また、src/pkg/exp/html/testlogs/以下の複数のテストログファイルが更新され、以前はFAILとなっていたテストケースがPASSまたはPARSEに変わっていることが示されています。これは、変更が正しく機能し、HTML5仕様への準拠度が向上したことを裏付けています。

コアとなるコードの解説

inTableIM関数は、HTMLパーサーがテーブル要素の内部にいるときに、次に受け取るトークンをどのように処理するかを決定する役割を担っています。この関数は、HTML5のツリー構築アルゴリズムにおける「in table」挿入モードのロジックを実装しています。

変更された主要な部分とその役割は以下の通りです。

  • case TextToken: ブロック:

    • p.tok.Data = strings.Replace(p.tok.Data, "\x00", "", -1): 入力されたテキストデータからヌル文字(\x00)を削除します。これは、HTMLのパースにおいてヌル文字が特殊な意味を持つ場合があるため、安全な処理を行うための一般的な前処理です。
    • switch p.oe.top().Data { ... }: 現在の要素スタックの最上位要素(つまり、現在パース中の要素の親)がtable, tbody, tfoot, thead, trのいずれかであるかをチェックします。
    • if strings.Trim(p.tok.Data, whitespace) == "" { ... }: テキストトークンが空白文字(スペース、タブ、改行など)のみで構成されているかをチェックします。strings.Trimは文字列の両端から指定された文字セットを削除し、結果が空文字列であれば空白のみであることを意味します。
    • p.addText(p.tok.Data): もしテキストトークンが空白のみであり、かつ親要素がテーブル関連の要素であれば、そのテキストノードを現在の要素に追加します。
    • return true: トークンの処理が完了したことを示し、次のトークンの処理に進みます。
    • この変更により、テーブル内部の空白のみのテキストノードがHTML5仕様に従って適切に処理され、不必要なフォスターペアレントが回避されます。
  • case StartTagToken: ブロック内の <td>, <th>, <tr> の処理:

    • p.parseImpliedToken(StartTagToken, "tbody", nil): これは、HTML5のパース規則において、<tr><td>タグが<tbody>タグなしで出現した場合でも、暗黙的に<tbody>が挿入されるべきであるというセマンティクスを実装しています。parseImpliedTokenは、あたかも<tbody>の開始タグが検出されたかのようにパーサーの状態を更新し、要素スタックに<tbody>を追加します。これにより、DOMツリーの構造的な整合性が保たれます。
    • return false: parseImpliedTokenが呼び出された後、現在のトークン(<td>, <th>, <tr>)の処理を続行するためにfalseを返します。
  • case StartTagToken: ブロック内の colgroupcol の処理:

    • case "colgroup": ... p.im = inColumnGroupIM: colgroupタグが検出された場合、要素スタックをtableScopeまでクリアし、colgroup要素を追加し、挿入モードをinColumnGroupIMに切り替えます。これは、colgroupがテーブルの列グループを定義するための要素であり、その内部のパースは異なるモードで行われるためです。
    • case "col": p.parseImpliedToken(StartTagToken, "colgroup", nil); return false: colタグが検出された場合、colgroupが暗黙的に挿入されるべきであるというHTML5の規則に従い、parseImpliedTokenを使用してcolgroupを挿入します。その後、colタグ自体の処理を続行するためにfalseを返します。
  • case StartTagToken: ブロック内の style, script, input, form の処理:

    • case "style", "script": return inHeadIM(p): テーブル内部で<style><script>タグが検出された場合、それらはテーブルのコンテンツとしてではなく、あたかも<head>要素内に存在するかのように処理されるべきであるというHTML5の規則に対応しています。inHeadIM(p)を呼び出すことで、パーサーは一時的に「in head」モードのロジックを適用し、これらの要素をDOMツリーの適切な場所(通常はhead要素の最後)に配置します。
    • case "input": ...: inputタグが検出された場合、そのtype属性がhiddenであるかをチェックします。type="hidden"の場合、要素を追加した直後にポップすることで、HTML5の隠し入力フィールドの特殊な処理(DOMツリーに一時的に追加された後、すぐに削除される)を模倣します。
    • case "form": ...: formタグが検出された場合、既にアクティブなフォーム要素が存在するか(p.form != nil)をチェックします。存在する場合は、HTML5でフォームのネストが許可されていないため、現在のformトークンを無視します。存在しない場合は、新しいフォーム要素を追加し、p.formに設定します。
  • case DoctypeToken: ブロック:

    • // Ignore the token. return true: テーブル内部でDOCTYPEトークンが検出された場合、それはHTMLとして不正であるため、単に無視します。

これらの変更は、HTML5の複雑なパース規則、特にテーブル要素の内部における要素の挿入、エラー回復、および暗黙的なタグ挿入のセマンティクスを正確に実装するために不可欠です。これにより、Go言語のHTMLパーサーは、より多くのHTML5テストケースをパスし、他のブラウザやツールとの互換性を向上させることができました。

関連リンク

参考にした情報源リンク

  • HTML Living Standard (WHATWG): https://html.spec.whatwg.org/
  • Go言語のexp/htmlパッケージのソースコード (コミット当時のもの、または現在のもの): https://cs.opensource.google/go/go/+/master:src/html/ (現在はexp/htmlからhtmlに移動している可能性があります)
  • HTML5に関する一般的な解説記事やチュートリアル (例: MDN Web Docsなど)
  • HTMLパーサーの実装に関する技術ブログや論文 (例: "The Story of the HTML5 Parser" など)
  • Go言語の公式ドキュメントやブログ記事 (該当する時期のもの)
  • stringsパッケージのドキュメント: https://pkg.go.dev/strings
  • Go言語のhtmlパッケージのドキュメント: https://pkg.go.dev/golang.org/x/net/html (現在のパッケージ名)
  • HTML5テストスイート (html5test.comなど、テストログに記載されているもの)