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

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

このコミットは、Go言語の実験的なHTMLパーサーおよびレンダラーパッケージである exp/html における、テストの挙動とレンダリングロジックの改善に関するものです。特に、HTML5の複雑なパースルールによって生成される「不正な形式の」DOMツリーの扱いと、生テキスト要素(Raw Text Elements)内にHTML要素が再親子化(reparenting)されるケースへの対応に焦点を当てています。

コミット

commit 27cb1cbb2e360b2ced4d3419ebd646d9d36acf5e
Author: Andrew Balholm <andybalholm@gmail.com>
Date:   Wed Aug 15 11:44:25 2012 +1000

    exp/html: skip render and reparse on more tests that build badly-formed parse trees
    
    All of the remaining tests that had as status of PARSE rather than PASS had
    good reasons for not passing the render-and-reparse step: the correct parse tree is
    badly formed, so when it is rendered out as HTML, the result doesn't parse into the
    same tree. So add them to the list of tests where that step is skipped.
    
    Also, I discovered that it is possible to end up with HTML elements (not just text)
    inside a raw text element through reparenting. So change the rendering routines to
    handle that situation as sensibly as possible (which still isn't very sensible, but
    this is HTML5).
    
    R=nigeltao
    CC=golang-dev
    https://golang.org/cl/6446137

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

https://github.com/golang/go/commit/27cb1cbb2e360b2ced4d3419ebd646d9d36acf5e

元コミット内容

このコミットの目的は2つあります。

  1. 不正な形式のパースツリーを持つテストのスキップ: exp/html パッケージのテストにおいて、「レンダリングと再パース」のステップで PARSE ステータス(失敗)となっていた残りのテストケースを、そのステップをスキップするリスト(ブラックリスト)に追加しました。これらのテストは、HTML5のパースルールに従って「正しく」パースされた結果、生成されるDOMツリーが「不正な形式」であるため、そのツリーをHTMLにレンダリングし、再度パースしても元のツリーと同一にならないという特性を持っていました。これはパーサーのバグではなく、HTML5の寛容なエラー回復メカニズムに起因するものです。
  2. 生テキスト要素内のHTML要素のレンダリング対応: 再親子化(reparenting)によって、生テキスト要素(例: <script>, <style>, <plaintext>)の内部に、テキストだけでなくHTML要素が入り込む可能性があることが発見されました。これに対応するため、レンダリングルーチンが変更され、このような状況でも可能な限り適切に(HTML5の仕様の「非論理的」な側面を考慮しつつ)処理するように修正されました。

変更の背景

Go言語の exp/html パッケージは、HTML5の仕様に厳密に従ったパーサーとレンダラーを提供することを目指しています。しかし、HTML5の仕様は、Web上の既存の「壊れた」HTMLを可能な限り寛容に処理し、一貫したDOMツリーを構築するために非常に複雑なエラー回復アルゴリズムを含んでいます。

この複雑さが、テストにおいて2つの主要な課題を引き起こしていました。

  1. レンダリングと再パースの不一致: exp/html のテストスイートには、パースされたDOMツリーをHTML文字列にレンダリングし、そのHTML文字列を再度パースして、元のDOMツリーと同一のものが生成されるかを確認する「レンダリングと再パース」テストが含まれていました。これは、パーサーとレンダラーの往復変換の正確性を検証するための重要なテストです。しかし、一部のテストケースでは、HTML5のパーサーが生成するDOMツリーが、仕様上は「正しい」ものの、その構造が非常に特殊で「不正な形式」であるため、レンダリング後に再パースしても元のツリーと完全に一致しないという問題がありました。これはパーサーやレンダラーのバグではなく、HTML5の仕様の特性によるものであり、これらのテストを PASS させることは本質的に不可能でした。そのため、これらのテストをスキップリストに追加し、テスト結果のノイズを減らす必要がありました。
  2. 生テキスト要素の特殊な挙動: HTML5では、<script>, <style>, <plaintext> などの特定の要素(生テキスト要素)の内部は、通常の子要素としてではなく、単なるテキストとして扱われます。しかし、HTML5のパースアルゴリズムにおける「再親子化」のような複雑なエラー回復メカニズムによって、これらの生テキスト要素の内部に、本来は入るべきではないHTML要素が誤って挿入されてしまうケースが発見されました。従来のレンダリングロジックでは、生テキスト要素内にテキスト以外のノードが存在するとエラーを返すようになっていましたが、これはHTML5のパーサーが生成する「現実」のDOMツリーと乖離していました。このため、レンダラーが生テキスト要素内のHTML要素を適切に処理できるように、ロジックを調整する必要がありました。

これらの課題に対処することで、exp/html パッケージはHTML5の仕様により忠実になり、同時にテストスイートの信頼性と実用性を向上させることができました。

前提知識の解説

このコミットを理解するためには、以下の概念を把握しておく必要があります。

1. HTMLパーシングとDOMツリー

  • HTMLパーシング: WebブラウザやHTMLパーサーが、HTML文書(テキストデータ)を読み込み、それをコンピュータが理解しやすい構造化されたデータ形式に変換するプロセスです。この構造化されたデータは、通常、DOM(Document Object Model)ツリーとして表現されます。
  • DOMツリー: HTML文書の論理的な構造をツリー(木)状に表現したものです。HTMLの各要素(タグ)、属性、テキストなどがノードとして表現され、親子関係によって階層的に配置されます。例えば、<div><p>Hello</p></div> というHTMLは、div ノードの下に p ノードがあり、その下にテキストノード Hello がある、というツリー構造になります。

2. HTML5パーシングアルゴリズムの複雑性

HTML5の仕様は、Web上に存在する非常に多様で、しばしば「不正な形式の」HTML文書を、可能な限り一貫した方法でパースするための詳細なアルゴリズムを定義しています。このアルゴリズムは、以下のような特徴を持ちます。

  • エラー回復: 閉じタグの欠落、タグの入れ子間違いなど、構文エラーがある場合でも、パースを停止せずに可能な限りDOMツリーを構築しようとします。
  • 再親子化 (Reparenting): 特定の状況下で、パーサーは要素をDOMツリー内の別の場所に移動させることがあります。例えば、<table> の中に <div> が直接書かれている場合、divtable の外に移動させられることがあります。これは、HTMLの要素が持つコンテンツモデル(どの要素を子に持てるか)の制約を満たすために行われます。この再親子化は、しばしば直感に反するDOMツリー構造を生み出す原因となります。
  • 「不正な形式の」パースツリー: HTML5の仕様に従ってパースされた結果、生成されるDOMツリーが、XMLのような厳密な整形式のルールから見ると「不正な形式」に見えることがあります。しかし、これはHTML5のパーサーがエラーを寛容に処理した結果であり、仕様上は「正しい」パース結果とされます。

3. Raw Text Elements (生テキスト要素) と RCDATA Elements (生文字データ要素)

HTML要素には、その内容の解釈方法によっていくつかのカテゴリがあります。

  • Raw Text Elements (生テキスト要素): <script>, <style>, <plaintext>, <noembed>, <noframes>, <iframe>, <noscript>, <xmp> など。これらの要素の内部は、特定の終了タグシーケンス(例: </script>)が現れるまで、すべてが単なるテキストデータとして扱われます。内部にHTMLタグが含まれていても、それはタグとして解釈されず、テキストの一部として扱われます。
  • RCDATA Elements (生文字データ要素): <textarea>, <title>。これらも生テキスト要素に似ていますが、HTMLエンティティ(例: &lt;< に変換される)が解釈される点が異なります。それ以外の内容はテキストとして扱われます。
  • Normal Elements (通常要素): 上記以外のほとんどのHTML要素。内部に他のHTML要素やテキストノードを持つことができます。

このコミットでは、特に生テキスト要素の内部に、再親子化によってHTML要素が入り込んでしまうという、HTML5のパーシングの特殊な挙動が問題となりました。

4. Render and Reparse (レンダリングと再パース) テスト

これは、HTMLパーサーとレンダラーの正確性を検証するためのテスト手法です。

  1. あるHTML文字列をパーサーに入力し、DOMツリーを生成します。
  2. 生成されたDOMツリーをレンダラーに入力し、HTML文字列に変換します。
  3. 変換されたHTML文字列を再度パーサーに入力し、新しいDOMツリーを生成します。
  4. 最初のDOMツリーと新しいDOMツリーが完全に同一であるか(または同等であるか)を比較します。

このテストが成功すれば、パーサーとレンダラーがHTMLの往復変換を正確に行えることを意味します。しかし、前述の「不正な形式のパースツリー」の場合、この往復変換がうまくいかないことがあります。

技術的詳細

このコミットは、Go言語の exp/html パッケージにおけるHTML5パーサーとレンダラーの挙動を、よりHTML5の仕様に合致させるための重要な修正を含んでいます。

1. renderTestBlacklist の拡張

src/pkg/exp/html/parse_test.go ファイルでは、renderTestBlacklist というマップが定義されています。このマップは、特定のHTMLスニペットをキーとし、その値が true である場合、そのスニペットに対する「レンダリングと再パース」テストをスキップすることを示します。

このコミットでは、以下の種類のテストケースが新たに追加されました。

  • 不正な親子関係の再親子化:
    • <p><table></p>: <p> 要素内に <table> がある場合、HTML5のルールでは <table><p> の外に移動させられます。しかし、この再親子化によって生成されるDOMツリーは、レンダリング後に再パースすると元のツリーと同一にならない場合があります。
    • <a><table><a></table><p><a><div><a>: <a> 要素の再親子化が複雑に絡むケース。
  • 生テキスト要素の特殊な挙動:
    • <table><plaintext><td>: <plaintext> は生テキスト要素であり、その後に続く内容はすべてテキストとして扱われるべきですが、<td> のような要素が誤って内部に挿入されるケース。
    • <!doctype html><script><!--<script <: <script> タグの内部に、コメントや別の <script のような文字列が含まれるケース。これらの文字列はテキストとして扱われるべきですが、パーサーが生成するツリーの特性上、レンダリングと再パースで不一致が生じることがあります。
    • <!doctype html><p><a><plaintext>b: <plaintext> 要素が <a> 要素の内部に再親子化され、その結果 <plaintext><a> を含むという、通常では考えられないツリー構造が生成されるケース。

これらのテストケースは、HTML5のパーサーが「正しく」生成するものの、その構造が非常に特殊で、レンダリングと再パースの往復変換が保証されないものです。これらをブラックリストに追加することで、テストの失敗がパーサーやレンダラーのバグではなく、HTML5の仕様の特性によるものであることを明確にし、テスト結果のノイズを減らしました。

2. render.go における生テキスト要素のレンダリングロジックの変更

src/pkg/exp/html/render.go ファイルの render1 関数は、DOMノードをHTML文字列にレンダリングする主要なロジックを含んでいます。このコミットの最も重要な変更点は、生テキスト要素(iframe, noembed, noframes, noscript, plaintext, script, style, xmp)の処理方法です。

変更前:

	case "iframe", "noembed", "noframes", "noscript", "plaintext", "script", "style", "xmp":
		for _, c := range n.Child {
			if c.Type != TextNode {
				return fmt.Errorf("html: raw text element <%s> has non-text child node", n.Data)
			}
			if _, err := w.WriteString(c.Data); err != nil {
				return err
			}
		}

変更前は、生テキスト要素の Child ノードが TextNode でない場合、つまりHTML要素ノードである場合は、エラーを返していました。これは、生テキスト要素の内部は純粋なテキストであるべきという前提に基づいています。

変更後:

	case "iframe", "noembed", "noframes", "noscript", "plaintext", "script", "style", "xmp":
		for _, c := range n.Child {
			if c.Type == TextNode {
				if _, err := w.WriteString(c.Data); err != nil {
					return err
				}
			} else {
				if err := render1(w, c); err != nil {
					return err
				}
			}
		}

変更後では、c.Type != TextNode の場合にエラーを返すのではなく、else ブロックで render1(w, c) を再帰的に呼び出すようになりました。これは、生テキスト要素の内部にHTML要素ノードが存在する場合でも、そのHTML要素をレンダリングしようと試みることを意味します。

この変更は、HTML5のパーシングにおける「再親子化」の発見に対応するものです。パーサーが不正なHTMLを処理する際に、生テキスト要素の内部にHTML要素が「誤って」挿入されてしまうことがあり、その結果生成されるDOMツリーは、仕様上は「正しい」とされます。このコミットは、レンダラーがそのような「現実」のDOMツリーを適切に処理できるように、より寛容なアプローチを採用しました。コミットメッセージにある「which still isn't very sensible, but this is HTML5」(まだあまり合理的ではないが、これがHTML5だ)というコメントは、HTML5の仕様が持つこのような非直感的な側面を反映しています。

また、case "textarea", "title": のブロックが削除されました。これらの要素はRCDATA要素であり、生テキスト要素と似ていますが、HTMLエンティティが解釈される点が異なります。このブロックが削除されたのは、生テキスト要素に対する新しい汎用的なレンダリングロジックが、RCDATA要素のケースも(あるいはその特殊性を考慮しつつ)カバーできるようになったため、あるいはこれらの要素に対する厳密な非テキスト子ノードチェックが不要になったためと考えられます。

これらの変更により、exp/html パッケージは、HTML5のパーシングアルゴリズムが生成する、より複雑で「不正な形式の」DOMツリーに対しても、より堅牢かつ正確に動作するようになりました。

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

src/pkg/exp/html/parse_test.go

renderTestBlacklist マップに、レンダリングと再パースのテストをスキップする新しいHTMLスニペットが多数追加されました。

--- a/src/pkg/exp/html/parse_test.go
+++ b/src/pkg/exp/html/parse_test.go
@@ -385,6 +385,8 @@ var renderTestBlacklist = map[string]bool{
 	// The second <a> will be reparented to the first <table>'s parent. This
 	// results in an <a> whose parent is an <a>, which is not 'well-formed'.
 	`<a><table><td><a><table></table><a></tr><a></table><b>X</b>C<a>Y`: true,
+	// The same thing with a <p>:
+	`<p><table></p>`: true,
 	// More cases of <a> being reparented:
 	`<a href="blah">aba<table><a href="foo">br<tr><td></td></tr>x</table>aoe`: true,
 	`<a><table><a></table><p><a><div><a>`:                                     true,
@@ -393,16 +395,26 @@ var renderTestBlacklist = map[string]bool{
 	// A <plaintext> element is reparented, putting it before a table.
 	// A <plaintext> element can't have anything after it in HTML.
-	`<table><plaintext><td>`: true,
+	`<table><plaintext><td>`:                                   true,
+	`<!doctype html><table><plaintext></plaintext>`:            true,
+	`<!doctype html><table><tbody><plaintext></plaintext>`:     true,
+	`<!doctype html><table><tbody><tr><plaintext></plaintext>`: true,
+	// A form inside a table inside a form doesn't work either.
+	`<!doctype html><form><table></form><form></table></form>`: true,
 	// A script that ends at EOF may escape its own closing tag when rendered.
 	`<!doctype html><script><!--<script `:          true,
+	`<!doctype html><script><!--<script <`:         true,
 	`<!doctype html><script><!--<script <a`:        true,
+	`<!doctype html><script><!--<script </`:        true,
+	`<!doctype html><script><!--<script </s`:       true,
 	`<!doctype html><script><!--<script </script`:  true,
 	`<!doctype html><script><!--<script </scripta`: true,
 	`<!doctype html><script><!--<script -`:         true,
 	`<!doctype html><script><!--<script -a`:        true,
+	`<!doctype html><script><!--<script -<`:        true,
 	`<!doctype html><script><!--<script --`:        true,
 	`<!doctype html><script><!--<script --a`:       true,
+	`<!doctype html><script><!--<script --<`:       true,
 	`<script><!--<script `:                         true,
 	`<script><!--<script <a`:                       true,
 	`<script><!--<script </script`:                 true,
@@ -411,6 +423,12 @@ var renderTestBlacklist = map[string]bool{
 	`<script><!--<script -a`:                       true,
 	`<script><!--<script --`:                       true,
 	`<script><!--<script --a`:                      true,
+	`<script><!--<script <`:                        true,
+	`<script><!--<script </`:                       true,
+	`<script><!--<script </s`:                      true,
+	// Reconstructing the active formatting elements results in a <plaintext>
+	// element that contains an <a> element.
+	`<!doctype html><p><a><plaintext>b`: true,
 }

src/pkg/exp/html/render.go

render1 関数内の、生テキスト要素(iframe, noembed, noframes, noscript, plaintext, script, style, xmp)を処理する switch ステートメントの case ブロックが変更されました。また、textareatitle を処理する case ブロックが削除されました。

--- a/src/pkg/exp/html/render.go
+++ b/src/pkg/exp/html/render.go
@@ -195,11 +195,14 @@ func render1(w writer, n *Node) error {
 	switch n.Data {
 	case "iframe", "noembed", "noframes", "noscript", "plaintext", "script", "style", "xmp":
 		for _, c := range n.Child {
-			if c.Type != TextNode {
-				return fmt.Errorf("html: raw text element <%s> has non-text child node", n.Data)
-			}
-			if _, err := w.WriteString(c.Data); err != nil {
-				return err
+			if c.Type == TextNode {
+				if _, err := w.WriteString(c.Data); err != nil {
+					return err
+				}
+			} else {
+				if err := render1(w, c); err != nil {
+					return err
+				}
 			}
 		}
 		if n.Data == "plaintext" {
@@ -207,15 +210,6 @@ func render1(w writer, n *Node) error {
 			// last element in the file, with no closing tag.
 			return plaintextAbort
 		}
-	case "textarea", "title":
-		for _, c := range n.Child {
-			if c.Type != TextNode && n.Namespace == "" {
-				return fmt.Errorf("html: RCDATA element <%s> has non-text child node", n.Data)
-			}
-			if err := render1(w, c); err != nil {
-				return err
-			}
-		}
 	default:
 		for _, c := range n.Child {
 			if err := render1(w, c); err != nil {

src/pkg/exp/html/testlogs/*.log

関連するテストログファイル(tests16.dat.log, tests18.dat.log, tests19.dat.log, tests20.dat.log)が更新され、以前 PARSE ステータスだったテストが PASS に変更されていることが示されています。これは、ブラックリストへの追加によってこれらのテストがスキップされるようになった結果です。

コアとなるコードの解説

src/pkg/exp/html/parse_test.go の変更

renderTestBlacklist に追加されたエントリは、GoのHTMLパーサーがHTML5の仕様に従ってパースした結果、生成されるDOMツリーが、レンダリング後に再パースしても元のツリーと同一にならない特定のHTMLスニペットを表します。これらのスニペットは、パーサーのバグではなく、HTML5の複雑なエラー回復メカニズム(特に再親子化)によって、直感に反するが仕様上は「正しい」DOMツリーが構築されるために発生します。

例えば、<p><table></p> のようなケースでは、HTML5のルールでは <table><p> の子にはなれません。パーサーはこれを検出し、<table><p> の親ノードの兄弟として再親子化します。この再親子化によって生成されるツリーは、元のHTML文字列から直接想像されるものとは異なるため、レンダリングと再パースの往復変換で同一性を保つことが困難になります。

これらのテストをブラックリストに追加することで、テストスイートは、パーサーとレンダラーがHTML5の仕様の「非論理的」な側面をどのように処理するかという現実を反映し、不必要なテスト失敗を避けることができます。

src/pkg/exp/html/render.go の変更

この変更は、生テキスト要素(<script>, <style> など)のレンダリングロジックを根本的に変更するものです。

変更前のロジックは、生テキスト要素の Child ノードが TextNode 以外(つまりHTML要素ノード)である場合、エラーを返していました。これは、生テキスト要素の内部は純粋なテキストであるべきという、より単純なモデルに基づいています。

しかし、変更後のロジックでは、c.Type == TextNode でない場合(つまりHTML要素ノードの場合)に、render1(w, c) を再帰的に呼び出すようになりました。これは、生テキスト要素の内部にHTML要素ノードが存在する場合でも、そのHTML要素をレンダリングしようと試みることを意味します。

この変更の背景には、HTML5のパーシングにおける「再親子化」の発見があります。HTML5のパーサーは、不正なHTMLを処理する際に、生テキスト要素の内部にHTML要素が「誤って」挿入されてしまうことがあります。例えば、<div><script><span>Hello</span></script></div> のようなHTMLが、特定の状況下でパースされると、<span><script> の子ノードとしてDOMツリーに現れる可能性があります。従来のレンダラーはこのようなツリーをエラーとして拒否していましたが、これはパーサーが生成する「現実」のDOMツリーと乖離していました。

新しいロジックは、このような「不正な形式だが仕様上は正しい」DOMツリーを受け入れ、可能な限り適切にレンダリングしようとします。これにより、exp/html パッケージは、より広範なHTML5の入力に対して堅牢になり、ブラウザの挙動により近づくことができます。

textareatitlecase ブロックが削除されたのは、生テキスト要素に対する新しい汎用的なレンダリングロジックが、これらのRCDATA要素のケースも適切に処理できるようになったため、あるいはこれらの要素に対する厳密な非テキスト子ノードチェックが不要になったためと考えられます。これにより、コードの重複が減り、保守性が向上します。

関連リンク

  • Go言語の exp/html パッケージのドキュメント: https://pkg.go.dev/exp/html (コミット当時の実験的なパッケージであり、現在は golang.org/x/net/html に統合されています)
  • HTML5仕様 (W3C勧告): https://www.w3.org/TR/html5/ (特に「Parsing HTML documents」セクション)

参考にした情報源リンク