---
title: "Extract visible text from HTML in C#"
url: https://recursiveai.org/onehtml/articles/extract-visible-text-from-html-csharp/
description: "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."
date: 2026-10-26
author: onehtml team
---

# 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.

October 26, 2026 · onehtml team

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 `&eacute;` 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.html · HTML*

```
<!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");
```

*Output · Console*

```
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`](https://dom.spec.whatwg.org/#dom-node-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](https://github.com/zzzprojects/html-agility-pack/issues/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](https://html-agility-pack.net/inner-text) 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](https://html.spec.whatwg.org/multipage/dom.html#the-innertext-idl-attribute), 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.cs · onehtml 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";
}
```

*Output · Console*

```
--- 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](https://recursiveai.org/onehtml/cookbook/computed-style-csharp/)). 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.cs · onehtml 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());
```

*Output · Console*

```
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](https://recursiveai.org/onehtml/docs/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.cs · HtmlAgilityPack 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)));
```

*Output · Console*

```
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](https://html-agility-pack.net/select-nodes)), hence the `??`.

## The same in AngleSharp

*Program.cs · AngleSharp 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)));
```

*Output · Console*

```
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

- [Parse HTML in C#](https://recursiveai.org/onehtml/articles/parse-html-csharp/): strings, files, URLs and encodings.
- [Extract visible text](https://recursiveai.org/onehtml/cookbook/extract-visible-text/) and [extract links and images](https://recursiveai.org/onehtml/cookbook/extract-links-and-images/), the cookbook recipes.
- [Remove scripts and event handlers](https://recursiveai.org/onehtml/cookbook/remove-scripts/) from a page while leaving the rest of the file untouched.
- [Migrating from HtmlAgilityPack](https://recursiveai.org/onehtml/articles/migrate-from-htmlagilitypack/): `InnerText`, `SelectNodes` and the rest, mapped to onehtml.
