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:
<!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–Friday, 9 to 6.<br>The café 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:
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");
HtmlAgilityPack InnerText: 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. 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);
TextContentincludes script text by definition. The DOM standard definestextContentas the text of every descendant text node, and the contents ofscriptandstyleare 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
InnerTextleft out the body’s script, but kept–andéas written. Script handling inInnerTextchanged in 2019. In issue #317, the maintainer described version 1.11.12 like this: “scriptandstyletext will only appear inInnerTextfromhead,scriptandstylenode.” The InnerText documentation describes it as “the text between the start and end tags of the object”. UseHtmlEntity.DeEntitizeto decode the references. - None of them apply CSS. The promotion hidden by
display: none, thehiddenparagraph and thenoscriptmessage 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
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"; }
--- 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:
Descendantswalks the tree in document order without recursion. It doesn’t entertemplatecontent.- Text inside
script,style,templateornoscriptis skipped.Closeststarts from an element and a text node isn’t one, so the test begins atnode.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 theBlockslist 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:
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());
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:
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)));
Opening hours Members save 10% Monday–Friday, 9 to 6.The café closes at 5. Draft: holiday hours Wi-FiPrintingScript 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
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)));
Opening hours Members save 10% Monday–Friday, 9 to 6.The café closes at 5. Draft: holiday hours Wi-FiPrintingRemoving 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
- Parse HTML in C#: strings, files, URLs and encodings.
- Extract visible text and extract links and images, the cookbook recipes.
- Remove scripts and event handlers from a page while leaving the rest of the file untouched.
- Migrating from HtmlAgilityPack:
InnerText,SelectNodesand the rest, mapped to onehtml.