Recursive AI

Extract visible text from HTML in C#

Get the readable text of an HTML page in C# without script, style or CSS-hidden content, and see why InnerText and TextContent return more than you see.

To extract the visible text from HTML in C#, walk the document’s text nodes and skip those inside script, style, template and noscript, starting a new line at each block element. To also drop text hidden by CSS, check each element’s computed display and visibility. The built-in properties don’t do this: TextContent in AngleSharp and onehtml includes script and style text because the DOM standard says it must, and HtmlAgilityPack’s InnerText leaves character references such as é undecoded. None of them apply CSS. Below, each library’s built-in property runs on the same page, followed by a visible-text function in onehtml and the equivalent fixes for HtmlAgilityPack and AngleSharp. The samples were compiled and run on .NET 10 against onehtml 1.0.0, HtmlAgilityPack 1.13.1 and AngleSharp 1.8.6.

The sample page

Every sample reads hours.html. It has a script in the head and one at the end of the body, a style sheet that hides the promotion, a paragraph with the hidden attribute, a noscript message and some character references:

hours.htmlHTML
<!DOCTYPE html>
<html lang="en">
<head>
  <title>Opening hours</title>
  <style>.promo { display: none }</style>
  <script>window.dataLayer = [];</script>
</head>
<body>
  <h1>Opening hours</h1>
  <p class="promo">Members save 10%</p>
  <p>Monday&ndash;Friday, 9 to 6.<br>The caf&eacute; closes at 5.</p>
  <p hidden>Draft: holiday hours</p>
  <ul><li>Wi-Fi</li><li>Printing</li></ul>
  <noscript>Turn on JavaScript to book a room.</noscript>
  <script>document.querySelector("h1").addEventListener("click", track);</script>
</body>
</html>

A browser shows five lines of text here: the heading, the two lines of the paragraph, and the two list items.

Why InnerText and TextContent include script and style

This program asks each library for the text of <body> with its built-in property. The output collapses whitespace so the three are easy to compare:

Program.cs.NET 10
using AngleSharp.Html.Parser;
using HtmlAgilityPack;
using OneHtml = RecursiveAI.OneHtml;
 
string html = File.ReadAllText("hours.html");
 
var hap = new HtmlDocument();
hap.LoadHtml(html);
string hapText = hap.DocumentNode.SelectSingleNode("//body")!.InnerText;
 
var angle = new HtmlParser().ParseDocument(html);
string angleText = angle.Body!.TextContent;
 
OneHtml.Document one = OneHtml.Document.Parse(html);
string oneText = one.Root.QuerySelector("body").TextContent!;
 
Show("HtmlAgilityPack InnerText", hapText);
Show("AngleSharp TextContent", angleText);
Show("onehtml TextContent", oneText);
 
// Collapse runs of whitespace so the three are easy to compare
static void Show(string label, string text) =>
    Console.WriteLine($"{label}:\n  {string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))}\n");
OutputConsole
HtmlAgilityPack InnerText:
  Opening hours Members save 10% Monday&ndash;Friday, 9 to 6.The caf&eacute; closes at 5. Draft: holiday hours Wi-FiPrinting Turn on JavaScript to book a room.
 
AngleSharp TextContent:
  Opening hours Members save 10% Monday–Friday, 9 to 6.The café closes at 5. Draft: holiday hours Wi-FiPrinting Turn on JavaScript to book a room. document.querySelector("h1").addEventListener("click", track);
 
onehtml TextContent:
  Opening hours Members save 10% Monday–Friday, 9 to 6.The café closes at 5. Draft: holiday hours Wi-FiPrinting Turn on JavaScript to book a room. document.querySelector("h1").addEventListener("click", track);
  • TextContent includes script text by definition. The DOM standard defines textContent as the text of every descendant text node, and the contents of script and style are text nodes. AngleSharp and onehtml both follow the standard, so the body’s script ends up in the result. This is correct behavior, not a bug.
  • HtmlAgilityPack’s InnerText left out the body’s script, but kept &ndash; and &eacute; as written. Script handling in InnerText changed in 2019. In issue #317, the maintainer described version 1.11.12 like this: “script and style text will only appear in InnerText from head, script and style node.” The InnerText documentation describes it as “the text between the start and end tags of the object”. Use HtmlEntity.DeEntitize to decode the references.
  • None of them apply CSS. The promotion hidden by display: none, the hidden paragraph and the noscript message are all included.
  • None of them add line breaks. <br> and block boundaries add no whitespace, so you get “6.The” and “Wi-FiPrinting”.

The property that matches what a browser shows is innerText in the HTML standard, and it is defined in terms of layout: it reads the rendered text, with line breaks where boxes break. A server-side parser has no layout, so the practical answer is to rebuild the useful part yourself: skip the elements that never render text, and break lines at block elements.

Extract visible text with onehtml

Program.csonehtml 1.0.0 · .NET 10
using System.Text;
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Css;
 
Document doc = Document.Parse(File.ReadAllBytes("hours.html"));
Node body = doc.Root.QuerySelector("body");
 
Console.WriteLine("--- markup only");
Console.WriteLine(VisibleText(body));
 
Console.WriteLine("--- with the cascade");
Console.WriteLine(VisibleText(body, DocumentStyles.Create(doc)));
 
// The text a reader sees, one line per block, without script, style, template or noscript content.
// With styles, text hidden by CSS (display: none, the hidden attribute, visibility: hidden) is left out too.
static string VisibleText(Node root, DocumentStyles? styles = null)
{
    const string Skip = "script, style, template, noscript";
    const string Blocks = "address, article, aside, blockquote, dd, details, div, dl, dt, figcaption, figure, footer, form, "
        + "h1, h2, h3, h4, h5, h6, header, li, main, nav, ol, p, pre, section, summary, table, td, th, tr, ul";
 
    var text = new StringBuilder();
    Node block = default;
    foreach (Node node in root.Descendants)
    {
        if (node.IsElement && node.LocalName == "br")
        {
            text.Append('\n');
        }
        else if (node.Kind == NodeKind.Text && node.ParentElement.Closest(Skip).IsNull
                 && (styles is null || !IsHidden(node.ParentElement, styles)))
        {
            // Start a new line whenever the text belongs to a different block than the last text did
            Node current = node.ParentElement.Closest(Blocks);
            if (current != block)
            {
                text.Append('\n');
                block = current;
            }
            text.Append(node.Data);
        }
    }
 
    // Collapse whitespace inside each line, as a browser does, and drop empty lines
    return string.Join('\n', text.ToString().Split('\n')
        .Select(line => string.Join(' ', line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)))
        .Where(line => line.Length > 0));
}
 
// display isn't inherited, so check every ancestor; visibility is, so the element's own value is enough
static bool IsHidden(Node element, DocumentStyles styles)
{
    for (Node e = element; !e.IsNull; e = e.ParentElement)
    {
        if (styles.GetStyle(e).GetPropertyValue("display") == "none")
        {
            return true;
        }
    }
    return styles.GetStyle(element).GetPropertyValue("visibility") is "hidden" or "collapse";
}
OutputConsole
--- markup only
Opening hours
Members save 10%
Monday–Friday, 9 to 6.
The café closes at 5.
Draft: holiday hours
Wi-Fi
Printing
--- with the cascade
Opening hours
Monday–Friday, 9 to 6.
The café closes at 5.
Wi-Fi
Printing

How it works:

  • Descendants walks the tree in document order without recursion. It doesn’t enter template content.
  • Text inside script, style, template or noscript is skipped. Closest starts from an element and a text node isn’t one, so the test begins at node.ParentElement.
  • Lines follow blocks. A text node starts a new line when its nearest block ancestor differs from the previous text’s, and <br> adds a break. Whitespace inside each line is then collapsed, roughly as a browser collapses it. Adjust the Blocks list to your markup.
  • Character references are already decoded by the parser, so “Monday–Friday” and “café” come out as text.

Leave out text hidden by CSS

Pass DocumentStyles.Create(doc) and the function also skips text whose element, or any ancestor, computes to display: none, plus text whose element computes to visibility: hidden. The hidden attribute is covered too, because the browser’s default style sheet, which onehtml applies, turns it into display: none. In the output above, “Members save 10%” and “Draft: holiday hours” are gone.

This is the CSS cascade, not layout. Inline <style> and style attributes are read; linked style sheets are used only when you supply them, since onehtml never fetches (see compute CSS styles). Text hidden by layout, such as zero-size boxes, clipping, off-screen positioning or text the same color as its background, is still included, and so is the content of a closed <details>. Text with aria-hidden="true" is included as well, because it is hidden from screen readers but still on screen.

What a screen reader reads: the reading view

When the question is what an assistive-technology user meets, rather than what is painted on screen, build the accessibility tree and read its reading view. It is part of the RecursiveAI.OneHtml.Accessibility package:

Program.csonehtml 1.0.0 · .NET 10
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Accessibility;
using RecursiveAI.OneHtml.Css;
 
Document doc = Document.Parse(File.ReadAllBytes("hours.html"));
 
// The accessibility tree is built with the cascade: CSS-hidden, hidden and aria-hidden content is left out
AccessibilityTree tree = AccessibilityTree.Build(doc, DocumentStyles.Create(doc));
Console.WriteLine(tree.CreateReadingView().ToText());
OutputConsole
heading level 1: Opening hours
Monday–Friday, 9 to 6.The café closes at 5.
list with 2 items
  item 1 of 2
  Wi-Fi
  item 2 of 2
  Printing
end of list

The reading view leaves out content hidden by display: none, visibility: hidden, the hidden attribute, aria-hidden, inert and closed details, and it announces headings and lists by role. It suits accessibility review and diffs. It isn’t a plain-text export: the runs of one paragraph are joined without a break (“6.The”), and a link inside a sentence becomes its own step. The wording is generic English, not what a particular screen reader says. Reading view has the rules.

The same in HtmlAgilityPack

If you are staying on HtmlAgilityPack, remove the elements whose text is never shown, then decode the references that InnerText leaves in:

Program.csHtmlAgilityPack 1.13.1 · .NET 10
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("hours.html");
 
// Remove the elements whose text is never shown, then decode the entities InnerText leaves in
foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//script|//style|//noscript|//template") ?? Enumerable.Empty<HtmlNode>())
{
    node.Remove();
}
 
string text = HtmlEntity.DeEntitize(doc.DocumentNode.SelectSingleNode("//body")!.InnerText);
Console.WriteLine(string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)));
OutputConsole
Opening hours Members save 10% Monday–Friday, 9 to 6.The café closes at 5. Draft: holiday hours Wi-FiPrinting

Script and noscript text are gone and the references are decoded. Content hidden by CSS or the hidden attribute is still there, since HtmlAgilityPack has no CSS engine, and the line breaks are still missing. SelectNodes returns null when nothing matches (SelectNodes), hence the ??.

The same in AngleSharp

Program.csAngleSharp 1.8.6 · .NET 10
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
 
var doc = new HtmlParser().ParseDocument(File.ReadAllText("hours.html"));
 
// Remove the elements whose text is never shown, then read TextContent
foreach (IElement element in doc.QuerySelectorAll("script, style, noscript, template"))
{
    element.Remove();
}
 
string text = doc.Body!.TextContent;
Console.WriteLine(string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)));
OutputConsole
Opening hours Members save 10% Monday–Friday, 9 to 6.The café closes at 5. Draft: holiday hours Wi-FiPrinting

Removing the elements changes the document, so do it on a copy if you need the original afterwards. With onehtml, documents are immutable and the walk above reads without changing anything.

Which approach to use

  • Search indexing, previews, diffs, feeding text to a model: the text walk. It needs only the parser, keeps paragraphs on their own lines, and leaves out script and style.
  • Pages that hide content with CSS (tabs, promotions, templates for later): the walk with DocumentStyles.
  • Checking what a screen-reader user hears: the reading view.
  • Pages built by JavaScript: none of these run script. Parse the DOM a browser produced, for example from Playwright’s Page.ContentAsync(), instead of the HTML the server sent.

Where next