Skip to content

Commit e73aa47

Browse files
committed
Use HtmlKit tokenizer for HTML5 parsing
Replaces the hand-rolled HTML scanner with HtmlKit tokenization and updates DOM parsing to better follow HTML5 behavior, including omitted end-tag handling (`p`/`td`/`tr`), safer comment/script handling, and `noscript` re-parsing. Adds anonymous table box correction logic and related DOM/CSS utilities, migrates text storage from `SubString` to `string` (removing `SubString.cs`), and includes a new demo sample showcasing malformed-HTML rendering improvements.
1 parent c4257c4 commit e73aa47

12 files changed

Lines changed: 546 additions & 416 deletions

File tree

Source/Demo/Common/HtmlRenderer.Demo.Common.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
<EmbeddedResource Include="Samples\12.HtmlToolTip.htm" />
8888
<EmbeddedResource Include="Samples\13.HtmlRender.htm" />
8989
<EmbeddedResource Include="Samples\14.HtmlContainer.htm" />
90+
<EmbeddedResource Include="Samples\15.HTML5 Parsing.htm" />
9091
<EmbeddedResource Include="Samples\20.About.htm" />
9192
<EmbeddedResource Include="PerfSamples\1.Big table.htm" />
9293
<EmbeddedResource Include="PerfSamples\2.Lots blocks in inline.htm" />
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<html>
2+
<head>
3+
<title>HTML5 Parsing</title>
4+
<link rel="Stylesheet" href="StyleSheet" />
5+
</head>
6+
<body>
7+
<h1>HTML5 Parsing
8+
</h1>
9+
<blockquote>
10+
<p>
11+
The html is parsed using a real <b>HTML5 tokenizer</b> (from the
12+
<a href="https://github.com/jstedfast/HtmlKit">HtmlKit</a> library) instead of a hand-rolled
13+
scanner, so the pages below - all of which are <i>intentionally malformed</i> - render
14+
correctly instead of breaking the layout. Every example shows the raw (broken) html
15+
source followed by the actual <b>live rendering</b> of that exact same markup.
16+
</p>
17+
<hr />
18+
<h2>Optional / omitted end tags
19+
</h2>
20+
<p>
21+
Real world html is full of tags whose closing tag was never written. The parser now
22+
follows the <a href="https://html.spec.whatwg.org/dev/dom.html#concept-element-tag-omission">
23+
HTML5 end-tag omission rules</a> and automatically closes them at the right
24+
place, instead of nesting everything into one deep, broken tree.
25+
</p>
26+
<div class="comment">Source:</div>
27+
<pre>&lt;p&gt;First paragraph, never closed
28+
&lt;p&gt;Second paragraph, never closed
29+
&lt;p&gt;Third paragraph, never closed</pre>
30+
<div class="comment">Live result:</div>
31+
<div class="example">
32+
<p>First paragraph, never closed
33+
<p>Second paragraph, never closed
34+
<p>Third paragraph, never closed
35+
</div>
36+
<hr />
37+
<h2>Tables with missing &lt;tr&gt;/&lt;td&gt; end tags
38+
</h2>
39+
<p>
40+
The same omission rules apply to table rows and cells, so a table written without a
41+
single closing <code>&lt;/tr&gt;</code> or <code>&lt;/td&gt;</code> still comes out as
42+
a proper grid.
43+
</p>
44+
<div class="comment">Source:</div>
45+
<pre>&lt;table border="1" cellpadding="4"&gt;
46+
&lt;tr&gt;&lt;td&gt;A&lt;td&gt;B
47+
&lt;tr&gt;&lt;td&gt;C&lt;td&gt;D
48+
&lt;/table&gt;</pre>
49+
<div class="comment">Live result:</div>
50+
<div class="example">
51+
<table border="1" cellpadding="4">
52+
<tr><td>A<td>B
53+
<tr><td>C<td>D
54+
</table>
55+
</div>
56+
<hr />
57+
<h2>Anonymous table boxes
58+
</h2>
59+
<p>
60+
Per the <a href="https://www.w3.org/TR/CSS2/tables.html#anonymous-boxes">CSS 2.1 anonymous
61+
table object rules</a>, a stray <code>&lt;td&gt;</code> with no <code>&lt;tr&gt;</code>
62+
(or even no <code>&lt;table&gt;</code>) around it is automatically wrapped in the missing
63+
row/table boxes, rather than producing a broken or invisible layout.
64+
</p>
65+
<div class="comment">Source:</div>
66+
<pre>&lt;table border="1"&gt;&lt;td&gt;Anonymous row generated around me&lt;/td&gt;&lt;/table&gt;</pre>
67+
<div class="comment">Live result:</div>
68+
<div class="example">
69+
<table border="1"><td>Anonymous row generated around me</td></table>
70+
</div>
71+
<hr />
72+
<h2>Comments containing "&gt;"
73+
</h2>
74+
<p>
75+
A real tokenizer knows where a comment actually ends, so a stray <code>&gt;</code> character
76+
inside a comment no longer truncates it early or corrupts the rest of the page.
77+
</p>
78+
<div class="comment">Source:</div>
79+
<pre>&lt;!-- a comment that contains a &gt; character right here --&gt;
80+
&lt;p&gt;This paragraph renders normally right after it.&lt;/p&gt;</pre>
81+
<div class="comment">Live result:</div>
82+
<div class="example">
83+
<!-- a comment that contains a > character right here -->
84+
<p>This paragraph renders normally right after it.</p>
85+
</div>
86+
<hr />
87+
<h2>&lt;script&gt; content is not mistaken for markup
88+
</h2>
89+
<p>
90+
<code>&lt;script&gt;</code> content is tokenized as raw text (this renderer does not execute
91+
script, so the code itself is not shown), which means <code>&lt;</code> and <code>&gt;</code>
92+
characters used for comparisons inside the script can no longer be misread as the start
93+
of a nested tag and swallow the rest of the document.
94+
</p>
95+
<div class="comment">Source:</div>
96+
<pre>&lt;script&gt;
97+
if (1 &lt; 2 &amp;&amp; 3 &gt; 2) { runDemo('&lt;b&gt;this looks like a tag but is just a string&lt;/b&gt;'); }
98+
&lt;/script&gt;
99+
&lt;p&gt;This paragraph still renders correctly right after the script block.&lt;/p&gt;</pre>
100+
<div class="comment">Live result:</div>
101+
<div class="example">
102+
<script>
103+
if (1 < 2 && 3 > 2) { runDemo('<b>this looks like a tag but is just a string</b>'); }
104+
</script>
105+
<p>This paragraph still renders correctly right after the script block.</p>
106+
</div>
107+
<hr />
108+
<h2>&lt;noscript&gt; content is parsed as real markup
109+
</h2>
110+
<p>
111+
Per the HTML5 tokenizer spec, <code>&lt;noscript&gt;</code> content is normally tokenized
112+
as raw text too. Since this renderer never runs script, its <code>&lt;noscript&gt;</code>
113+
content is specifically re-parsed as nested html, so tags placed inside it render as real
114+
elements instead of showing up as literal text.
115+
</p>
116+
<div class="comment">Source:</div>
117+
<pre>&lt;noscript&gt;&lt;p&gt;This is &lt;b&gt;real, nested markup&lt;/b&gt; inside noscript.&lt;/p&gt;&lt;/noscript&gt;</pre>
118+
<div class="comment">Live result:</div>
119+
<div class="example">
120+
<noscript><p>This is <b>real, nested markup</b> inside noscript.</p></noscript>
121+
</div>
122+
</blockquote>
123+
</body>
124+
</html>

Source/HtmlRenderer/Core/Dom/CssBox.cs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ internal class CssBox : CssBoxProperties, IDisposable
6060
/// <summary>
6161
/// the inner text of the box
6262
/// </summary>
63-
private SubString _text;
63+
private string _text;
6464

6565
/// <summary>
6666
/// Do not use or alter this flag
@@ -146,6 +146,14 @@ public bool IsBrElement
146146
}
147147
}
148148

149+
/// <summary>
150+
/// Is the box "Display" is one of the table-row-group/table-header-group/table-footer-group values.
151+
/// </summary>
152+
public bool IsTableRowGroupBox
153+
{
154+
get { return Display == CssConstants.TableRowGroup || Display == CssConstants.TableHeaderGroup || Display == CssConstants.TableFooterGroup; }
155+
}
156+
149157
/// <summary>
150158
/// is the box "Display" is "Inline", is this is an inline box and not block.
151159
/// </summary>
@@ -278,7 +286,7 @@ public bool IsSpaceOrEmpty
278286
/// <summary>
279287
/// Gets or sets the inner text of the box
280288
/// </summary>
281-
public SubString Text
289+
public string Text
282290
{
283291
get { return _text; }
284292
set
@@ -541,7 +549,7 @@ public void ParseToWords()
541549

542550
int startIdx = 0;
543551
bool preserveSpaces = WhiteSpace == CssConstants.Pre || WhiteSpace == CssConstants.PreWrap;
544-
bool respoctNewline = preserveSpaces || WhiteSpace == CssConstants.PreLine;
552+
bool respoctNewline = preserveSpaces || WhiteSpace == CssConstants.PreLine || IsBrElement;
545553
while (startIdx < _text.Length)
546554
{
547555
while (startIdx < _text.Length && _text[startIdx] == '\r')
@@ -795,27 +803,27 @@ private void CreateListItemBox(RGraphics g)
795803

796804
if (ListStyleType.Equals(CssConstants.Disc, StringComparison.InvariantCultureIgnoreCase))
797805
{
798-
_listItemBox.Text = new SubString("•");
806+
_listItemBox.Text = "•";
799807
}
800808
else if (ListStyleType.Equals(CssConstants.Circle, StringComparison.InvariantCultureIgnoreCase))
801809
{
802-
_listItemBox.Text = new SubString("o");
810+
_listItemBox.Text = "o";
803811
}
804812
else if (ListStyleType.Equals(CssConstants.Square, StringComparison.InvariantCultureIgnoreCase))
805813
{
806-
_listItemBox.Text = new SubString("♠");
814+
_listItemBox.Text = "♠";
807815
}
808816
else if (ListStyleType.Equals(CssConstants.Decimal, StringComparison.InvariantCultureIgnoreCase))
809817
{
810-
_listItemBox.Text = new SubString(GetIndexForList().ToString(CultureInfo.InvariantCulture) + ".");
818+
_listItemBox.Text = GetIndexForList().ToString(CultureInfo.InvariantCulture) + ".";
811819
}
812820
else if (ListStyleType.Equals(CssConstants.DecimalLeadingZero, StringComparison.InvariantCultureIgnoreCase))
813821
{
814-
_listItemBox.Text = new SubString(GetIndexForList().ToString("00", CultureInfo.InvariantCulture) + ".");
822+
_listItemBox.Text = GetIndexForList().ToString("00", CultureInfo.InvariantCulture) + ".";
815823
}
816824
else
817825
{
818-
_listItemBox.Text = new SubString(CommonUtils.ConvertToAlphaNumber(GetIndexForList(), ListStyleType) + ".");
826+
_listItemBox.Text = CommonUtils.ConvertToAlphaNumber(GetIndexForList(), ListStyleType) + ".";
819827
}
820828

821829
_listItemBox.ParseToWords();

Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ private static void FlowBox(RGraphics g, CssBox blockbox, CssBox box, double lim
344344
}
345345

346346
// handle box that is only a whitespace
347-
if (box.Text != null && box.Text.IsWhitespace() && !box.IsImage && box.IsInline && box.Boxes.Count == 0 && box.Words.Count == 0)
347+
if (box.Text != null && CommonUtils.IsNonEmptyWhitespace(box.Text) && !box.IsImage && box.IsInline && box.Boxes.Count == 0 && box.Words.Count == 0)
348348
{
349349
curx += box.ActualWordSpacing;
350350
}

0 commit comments

Comments
 (0)