Skip to content

Commit 7ab0f96

Browse files
committed
Ignore style/script elements by default, allow opting for other elements too
script/style are typically not part of the content of a page, yet they can be quite large blocks of text that would consume memory unnecessarily. We should skip both by default, and allow opting in for those as well as potentially other elements via settings. This commit reworks the way we deal with settings so all overloads have a consistent and non-duplicated behavior. Defaulting the HTML 4.01 transitional DTD also allows for much cleaner object model, so we opt in for that too. Fixes #8
1 parent 4d428bd commit 7ab0f96

8 files changed

Lines changed: 193 additions & 76 deletions

File tree

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ Implements CSS selectors for XLinq.
1010
# Usage
1111

1212
```csharp
13-
using Devlooped.Web;
1413
using System.Xml.Linq;
14+
using Devlooped.Web;
1515

1616
XDocument page = HtmlDocument.Load("page.html")
1717
IEnumerable<XElement> elements = page.CssSelectElements("div.menuitem");

src/Html/Html.csproj

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,17 @@
88
<PackageProjectUrl>https://clarius.org/html</PackageProjectUrl>
99
<PackageReadmeFile>readme.md</PackageReadmeFile>
1010
<Description>Read HTML as XML using XLinq.</Description>
11+
<LangVersion>Preview</LangVersion>
1112
</PropertyGroup>
1213

1314
<ItemGroup>
14-
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1">
15-
<PrivateAssets>all</PrivateAssets>
16-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
17-
</PackageReference>
15+
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="all" />
1816
<PackageReference Include="Microsoft.Xml.SgmlReader" Version="1.8.25" />
1917
<PackageReference Include="NuGetizer" Version="0.8.0" PrivateAssets="all" />
2018
</ItemGroup>
2119

2220
<ItemGroup>
2321
<None Include="..\..\readme.md" PackagePath="readme.md" />
2422
</ItemGroup>
25-
23+
2624
</Project>

src/Html/HtmlDocument.cs

Lines changed: 84 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ namespace Devlooped.Html;
1212
/// </summary>
1313
public static class HtmlDocument
1414
{
15+
const string DefaultPublicIdentifier = "-//W3C//DTD XHTML 1.0 Transitional//EN";
16+
const string DefaultSystemLiteral = "http://www.w3.org/TR/html4/loose.dtd";
17+
18+
/// <summary>
19+
/// Create a new <see cref="XDocument"/> and initialize its underlying XML tree using
20+
/// the passed <see cref="Stream"/> parameter.
21+
/// </summary>
22+
/// <param name="stream">
23+
/// A <see cref="Stream"/> containing the raw HTML to read into the newly
24+
/// created <see cref="XDocument"/>.
25+
/// </param>
26+
/// <returns>
27+
/// A new <see cref="XDocument"/> containing the contents of the passed in
28+
/// <see cref="Stream"/>.
29+
/// </returns>
30+
public static XDocument Load(Stream stream) => Load(stream, HtmlReaderSettings.Default);
31+
1532
/// <overloads>
1633
/// The Load method provides multiple strategies for creating a new
1734
/// <see cref="XDocument"/> and initializing it from a data source containing
@@ -35,11 +52,22 @@ public static class HtmlDocument
3552
/// An <see cref="XDocument"/> initialized with the contents of the file referenced
3653
/// in the passed in uri parameter.
3754
/// </returns>
38-
public static XDocument Load(string uri)
39-
{
40-
using var stream = (Stream)new XmlUrlResolver().GetEntity(new Uri(uri), string.Empty, typeof(Stream));
41-
return Load(stream);
42-
}
55+
public static XDocument Load(string uri) => Load(uri, HtmlReaderSettings.Default);
56+
57+
/// <summary>
58+
/// Create a new <see cref="XDocument"/> and initialize its underlying XML tree using
59+
/// the passed <see cref="TextReader"/> parameter. Optionally whitespace handling
60+
/// can be preserved.
61+
/// </summary>
62+
/// <param name="textReader">
63+
/// A <see cref="TextReader"/> containing the raw HTML to read into the newly
64+
/// created <see cref="XDocument"/>.
65+
/// </param>
66+
/// <returns>
67+
/// A new <see cref="XDocument"/> containing the contents of the passed in
68+
/// <see cref="TextReader"/>.
69+
/// </returns>
70+
public static XDocument Load(TextReader textReader) => Load(textReader, HtmlReaderSettings.Default);
4371

4472
/// <summary>
4573
/// Create a new <see cref="XDocument"/> and initialize its underlying XML tree using
@@ -49,12 +77,13 @@ public static XDocument Load(string uri)
4977
/// A <see cref="Stream"/> containing the raw HTML to read into the newly
5078
/// created <see cref="XDocument"/>.
5179
/// </param>
80+
/// <param name="settings">The settings for the HTML load process.</param>
5281
/// <returns>
5382
/// A new <see cref="XDocument"/> containing the contents of the passed in
5483
/// <see cref="Stream"/>.
5584
/// </returns>
56-
public static XDocument Load(Stream stream)
57-
=> Load(new StreamReader(stream));
85+
public static XDocument Load(Stream stream, HtmlReaderSettings settings)
86+
=> Load(new StreamReader(stream), settings);
5887

5988
/// <overloads>
6089
/// The Load method provides multiple strategies for creating a new
@@ -81,48 +110,13 @@ public static XDocument Load(Stream stream)
81110
/// in the passed in uri parameter.
82111
/// </returns>
83112
public static XDocument Load(string uri, HtmlReaderSettings settings)
84-
{
85-
using var stream = (Stream)new XmlUrlResolver().GetEntity(new Uri(uri), string.Empty, typeof(Stream));
86-
return Load(stream, settings);
87-
}
88-
89-
/// <summary>
90-
/// Create a new <see cref="XDocument"/> and initialize its underlying XML tree using
91-
/// the passed <see cref="Stream"/> parameter.
92-
/// </summary>
93-
/// <param name="stream">
94-
/// A <see cref="Stream"/> containing the raw HTML to read into the newly
95-
/// created <see cref="XDocument"/>.
96-
/// </param>
97-
/// <param name="settings">The settings for the HTML load process.</param>
98-
/// <returns>
99-
/// A new <see cref="XDocument"/> containing the contents of the passed in
100-
/// <see cref="Stream"/>.
101-
/// </returns>
102-
public static XDocument Load(Stream stream, HtmlReaderSettings settings)
103-
=> Load(new StreamReader(stream), settings);
104-
105-
/// <summary>
106-
/// Create a new <see cref="XDocument"/> and initialize its underlying XML tree using
107-
/// the passed <see cref="TextReader"/> parameter. Optionally whitespace handling
108-
/// can be preserved.
109-
/// </summary>
110-
/// <param name="textReader">
111-
/// A <see cref="TextReader"/> containing the raw HTML to read into the newly
112-
/// created <see cref="XDocument"/>.
113-
/// </param>
114-
/// <returns>
115-
/// A new <see cref="XDocument"/> containing the contents of the passed in
116-
/// <see cref="TextReader"/>.
117-
/// </returns>
118-
public static XDocument Load(TextReader textReader)
119113
{
120114
using var reader = new SgmlReader
121115
{
122-
InputStream = textReader,
116+
Href = uri,
123117
};
124118

125-
return XDocument.Load(new IgnoreXmlNsReader(reader));
119+
return XDocument.Load(Configure(reader, settings));
126120
}
127121

128122
/// <summary>
@@ -144,12 +138,9 @@ public static XDocument Load(TextReader textReader, HtmlReaderSettings settings)
144138
using var reader = new SgmlReader
145139
{
146140
InputStream = textReader,
147-
CaseFolding = settings.CaseFolding,
148-
WhitespaceHandling = settings.WhitespaceHandling,
149-
TextWhitespace = settings.TextWhitespace,
150141
};
151142

152-
return XDocument.Load(settings.IgnoreXmlNamespaces ? new IgnoreXmlNsReader(reader) : reader);
143+
return XDocument.Load(Configure(reader, settings));
153144
}
154145

155146
/// <overloads>
@@ -165,8 +156,7 @@ public static XDocument Load(TextReader textReader, HtmlReaderSettings settings)
165156
/// An <see cref="XDocument"/> containing an XML tree initialized from the
166157
/// passed in HTML string.
167158
/// </returns>
168-
public static XDocument Parse(string html)
169-
=> Load(new StringReader(html));
159+
public static XDocument Parse(string html) => Parse(html, HtmlReaderSettings.Default);
170160

171161
/// <overloads>
172162
/// Create a new <see cref="XDocument"/> from a string containing
@@ -187,6 +177,27 @@ public static XDocument Parse(string html)
187177
public static XDocument Parse(string html, HtmlReaderSettings settings)
188178
=> Load(new StringReader(html), settings);
189179

180+
static XmlReader Configure(SgmlReader reader, HtmlReaderSettings settings)
181+
{
182+
reader.DocType = "html";
183+
reader.PublicIdentifier = DefaultPublicIdentifier;
184+
reader.SystemLiteral = DefaultSystemLiteral;
185+
186+
reader.CaseFolding = settings.CaseFolding;
187+
reader.WhitespaceHandling = settings.WhitespaceHandling;
188+
reader.TextWhitespace = settings.TextWhitespace;
189+
190+
XmlReader result = reader;
191+
192+
if (settings.IgnoreXmlNamespaces)
193+
result = new IgnoreXmlNsReader(result);
194+
195+
if (settings.SkipElements.Length > 0)
196+
result = new SkipElementsReader(result, settings.SkipElements);
197+
198+
return result;
199+
}
200+
190201
/// <summary>
191202
/// Removes all XML namespaces, since for HTML content it's typically
192203
/// irrelevant.
@@ -242,5 +253,28 @@ public override bool MoveToNextAttribute()
242253

243254
bool IsLocalXmlNs => Prefix == "xmlns";
244255
}
256+
257+
/// <summary>
258+
/// Removes all XML namespaces, since for HTML content it's typically
259+
/// irrelevant.
260+
/// </summary>
261+
class SkipElementsReader : XmlWrappingReader
262+
{
263+
readonly HashSet<string> skipElements;
264+
265+
public SkipElementsReader(XmlReader baseReader, string[] skipElements) : base(baseReader)
266+
{
267+
this.skipElements = new HashSet<string>(skipElements, StringComparer.OrdinalIgnoreCase);
268+
}
269+
270+
public override bool Read()
271+
{
272+
var read = base.Read();
273+
if (read && base.NodeType == XmlNodeType.Element && skipElements.Contains(LocalName))
274+
base.Skip();
275+
276+
return read;
277+
}
278+
}
245279
}
246280

src/Html/HtmlReaderSettings.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ namespace Devlooped.Html;
1010
/// </summary>
1111
public sealed class HtmlReaderSettings
1212
{
13+
/// <summary>
14+
/// Default settings when reading HTML, which are:
15+
/// <see cref="CaseFolding.ToLower" />, <see cref="IgnoreXmlNamespaces"/>=true
16+
/// and <see cref="SkipElements"/>=["script", "style"].
17+
/// </summary>
18+
public static HtmlReaderSettings Default { get; } = new HtmlReaderSettings
19+
{
20+
CaseFolding = CaseFolding.ToLower,
21+
IgnoreXmlNamespaces = true,
22+
SkipElements = new string[] { "script", "style" },
23+
};
24+
1325
/// <summary>
1426
/// HTML is case insensitive, so you can choose between converting
1527
/// to lower case or upper case tags. "None" means that the case is left
@@ -22,6 +34,12 @@ public sealed class HtmlReaderSettings
2234
/// </summary>
2335
public bool IgnoreXmlNamespaces { get; set; } = true;
2436

37+
/// <summary>
38+
/// Elements that should be skipped when reading the HTML so they are
39+
/// not loaded into the resulting XML document. Defaults to no elements.
40+
/// </summary>
41+
public string[] SkipElements { get; set; } = Array.Empty<string>();
42+
2543
/// <summary>
2644
/// Specifies how leading and trailing whitespace is handled.
2745
/// Note that this is a <see cref="FlagsAttribute"/>-enum.

src/Tests/HtmlTests.cs

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Xml;
2+
using System.Xml.Linq;
13
using System.Xml.XPath;
24
using Devlooped.Html;
35

@@ -6,56 +8,95 @@ namespace Devlooped.Tests;
68
public record HtmlTests(ITestOutputHelper Output)
79
{
810
[Fact]
9-
public void IncludesScripts()
11+
public void Render()
1012
{
11-
var doc = HtmlDocument.Load(new Uri("file://" + new FileInfo("wikipedia.html").FullName).AbsoluteUri);
13+
var doc = HtmlDocument.Load(File("sample.html"));
14+
15+
Output.WriteLine(doc.ToString());
16+
}
17+
18+
[Fact]
19+
public void ExcludesScriptsByDefault()
20+
{
21+
var doc = HtmlDocument.Load(File("wikipedia.html"));
22+
23+
Assert.Empty(doc.XPathSelectElements("//script"));
24+
}
25+
26+
[Fact]
27+
public void IncludeScriptsExplicitSettings()
28+
{
29+
var doc = HtmlDocument.Load(File("wikipedia.html"), new HtmlReaderSettings());
1230

1331
Assert.NotEmpty(doc.XPathSelectElements("//script"));
1432
}
1533

1634
[Fact]
17-
public void IncludesStyles()
35+
public void ExcludesStylesByDefault()
1836
{
19-
var doc = HtmlDocument.Load(new Uri("file://" + new FileInfo("wikipedia.html").FullName).AbsoluteUri);
37+
var doc = HtmlDocument.Load(File("wikipedia.html"));
38+
39+
Assert.Empty(doc.XPathSelectElements("//style"));
40+
}
41+
42+
[Fact]
43+
public void IncludeStylesExplicitSettings()
44+
{
45+
var doc = HtmlDocument.Load(File("wikipedia.html"), new HtmlReaderSettings());
2046

2147
Assert.NotEmpty(doc.XPathSelectElements("//style"));
2248
}
2349

2450
[Fact]
25-
public void RemovesXmlNamespaces()
51+
public void ExcludesXmlNamespacesByDefault()
2652
{
27-
var doc = HtmlDocument.Load(new Uri("file://" + new FileInfo("sample.xhtml").FullName).AbsoluteUri);
53+
var doc = HtmlDocument.Load(File("sample.xhtml"));
2854

2955
Assert.NotEmpty(doc.XPathSelectElements("//h1"));
3056
}
3157

3258
[Fact]
33-
public void HtmlSettings()
59+
public void IncludeXmlNamespacesExplicitly()
60+
{
61+
var doc = HtmlDocument.Load(File("sample.xhtml"), new HtmlReaderSettings { IgnoreXmlNamespaces = false });
62+
var resolver = new XmlNamespaceManager(new NameTable());
63+
resolver.AddNamespace("xh", "http://www.w3.org/1999/xhtml");
64+
65+
Assert.NotEmpty(doc.XPathSelectElements("//xh:h1", resolver));
66+
// Won't match because the elements will have the XHTML namespace
67+
Assert.Empty(doc.XPathSelectElements("//h1"));
68+
}
69+
70+
[Fact]
71+
public void CanChangeToUpperCaseHtml()
3472
{
35-
var doc = HtmlDocument.Load(new Uri("file://" + new FileInfo("wikipedia.html").FullName).AbsoluteUri,
73+
var doc = HtmlDocument.Load(File("wikipedia.html"),
3674
new HtmlReaderSettings
3775
{
3876
CaseFolding = Sgml.CaseFolding.ToUpper,
39-
TextWhitespace = Sgml.TextWhitespaceHandling.TrimBoth,
40-
WhitespaceHandling = System.Xml.WhitespaceHandling.None
4177
});
4278

4379
// The source has lowercase elements
4480
var central = doc.XPathSelectElement("/HTML/BODY/DIV/H1/SPAN");
4581

4682
Assert.NotNull(central);
47-
48-
// The source contains leading and trailing whitespaces.
49-
Assert.Equal("Wikipedia", central!.Value);
5083
}
5184

5285
[Fact]
53-
public void OptOutOfXmlNamespacesRemoval()
86+
public void HtmlSettings()
5487
{
55-
var doc = HtmlDocument.Load(new Uri("file://" + new FileInfo("sample.xhtml").FullName).AbsoluteUri,
56-
new HtmlReaderSettings { IgnoreXmlNamespaces = false });
88+
var doc = HtmlDocument.Load(File("wikipedia.html"),
89+
new HtmlReaderSettings
90+
{
91+
TextWhitespace = Sgml.TextWhitespaceHandling.TrimBoth,
92+
WhitespaceHandling = WhitespaceHandling.None
93+
});
5794

58-
// Won't match because the elements will have the XHTML namespace
59-
Assert.Empty(doc.XPathSelectElements("//h1"));
95+
var central = doc.XPathSelectElement("/html/body/div/h1/span");
96+
97+
// The source contains leading and trailing whitespaces.
98+
Assert.Equal("Wikipedia", central?.Value);
6099
}
100+
101+
string File(string path) => new Uri("file://" + new FileInfo(path).FullName).AbsoluteUri;
61102
}

0 commit comments

Comments
 (0)