Skip to content

HTML Preprocessing

Real-world HTML rarely converts cleanly as-is. Pages carry navigation chrome, tracking markup, styling wrappers and inline CSS that have no place in Markdown. Config.Preprocess is an ordered pipeline of transformations applied to the source HTML, before it is read into the Markdown DOM, so you can shape the input instead of post-processing the output.

source HTML  ->  [ Preprocess steps ]  ->  Html filters  ->  Markdown DOM  ->  writer  ->  Markdown

Quick start

csharp
var config = new Config();
config.Preprocess
    .RemoveScripts()                        // <script>/<noscript> and on* handlers
    .RemoveStyles()                         // style attributes, <style>, stylesheet <link>
    .Remove("nav, footer, .advertisement")  // drop chrome
    .Unwrap("span, font")                   // keep the text, lose the wrapper
    .Rename("b", "strong")                  // <b> -> <strong>
    .RemoveEmptyElements();                 // collapse leftover empty wrappers

var markdown = new Converter(config).Convert(Html);
snippet source | anchor

Every helper appends a step and returns the pipeline, so calls chain. Steps run in the order they were added, each one seeing the result of the previous step: Rename("h1", "h2") followed by Remove("h2") is not the same as the reverse.

Removing content

At a glance: Remove | RemoveWhere | KeepOnly | RemoveComments | RemoveEmptyElements

Remove

Remove(selector) removes matching elements and their content.

csharp
var config = new Config();
config.Preprocess.Remove("nav, footer, .advertisement");

var markdown = new Converter(config).Convert(
    "<nav>menu</nav><p>Body text.</p><footer>fine print</footer>");
// Body text.
snippet source | anchor

RemoveWhere

RemoveWhere(predicate) removes elements for which the predicate returns true, for conditions a CSS selector cannot express.

csharp
var config = new Config();
config.Preprocess.RemoveWhere(e => e.GetAttribute("data-role") == "promo");

var markdown = new Converter(config).Convert(
    "<p>Real content.</p><p data-role=\"promo\">Buy now!</p>");
// Real content.
snippet source | anchor

KeepOnly

KeepOnly(selector) reduces the document to the matching elements, dropping everything else. Nested matches are kept via their outermost match rather than duplicated. If nothing matches, the document is left untouched, so a typo cannot silently empty the output.

csharp
var config = new Config();
config.Preprocess.KeepOnly("article");

var markdown = new Converter(config).Convert(
    "<header>site nav</header><article><h1>Title</h1><p>Body.</p></article><footer>end</footer>");
// # Title
//
// Body.
snippet source | anchor

RemoveComments

RemoveComments() drops HTML comments from the source. Unlike Formatting.RemoveComments, which works on the conversion output, this removes them before anything else runs.

csharp
var config = new Config();
config.Preprocess.RemoveComments();

var markdown = new Converter(config).Convert("<p>Visible<!-- editor note --> text.</p>");
// Visible text.
snippet source | anchor

RemoveEmptyElements

RemoveEmptyElements(selector = "p, div, span") removes matching elements with no text and no meaningful content. Nested wrappers collapse in one pass; elements holding images, tables or line breaks are kept.

csharp
var config = new Config();
config.Preprocess.RemoveEmptyElements();

var markdown = new Converter(config).Convert(
    "<p>Kept.</p><p></p><div><span>  </span></div><p>Also kept.</p>");
// Kept.
//
// Also kept.
snippet source | anchor

Transforming elements and content

At a glance: Rename | Unwrap | Wrap | ReplaceWith | ReplaceWithText | Transform | ReplaceText

Rename

Rename(selector, tagName) changes the tag, keeping attributes and children.

csharp
var config = new Config();
config.Preprocess.Rename("h1", "h2");   // demote headings a level

var markdown = new Converter(config).Convert("<h1>Title</h1><p>Body.</p>");
// ## Title
//
// Body.
snippet source | anchor

Unwrap

Unwrap(selector) removes the element but keeps its children. Unwrap("span, font") strips styling wrappers; Unwrap("a") keeps link text without the link.

csharp
var config = new Config();
config.Preprocess.Unwrap("span");

var markdown = new Converter(config).Convert("<p>a <span>b <em>c</em></span> d</p>");
// a b *c* d
snippet source | anchor

Wrap

Wrap(selector, tagName) wraps matching elements in a new element.

csharp
var config = new Config();
config.Preprocess.Wrap("p.note", "blockquote");

var markdown = new Converter(config).Convert("<p class=\"note\">Remember this.</p>");
// > Remember this.
snippet source | anchor

ReplaceWith

ReplaceWith(selector, html) replaces the element and its content with an HTML fragment.

csharp
var config = new Config();
config.Preprocess.ReplaceWith("div.callout", "<blockquote>Note</blockquote>");

var markdown = new Converter(config).Convert(
    "<p>Before.</p><div class=\"callout\"><span>markup we do not want</span></div>");
// Before.
//
// > Note
snippet source | anchor

ReplaceWithText

ReplaceWithText(selector, text) replaces the element with a text node. The text is inserted as content, not markup, so it is escaped like any other text.

csharp
var config = new Config();
config.Preprocess.ReplaceWithText("img.emoji", ":smile:");

var markdown = new Converter(config).Convert(
    "<p>Hi <img class=\"emoji\" src=\"smile.png\" alt=\"smile\"> there.</p>");
// Hi :smile: there.
snippet source | anchor

Transform

Transform(selector, action) runs your action against every match: the escape hatch for rewriting attributes, moving nodes and anything else.

csharp
var config = new Config();
config.Preprocess.Transform(
    "a[href^='/']",
    a => a.SetAttribute("href", "https://example.com" + a.GetAttribute("href")));

var markdown = new Converter(config).Convert("<p><a href=\"/docs\">the docs</a></p>");
// [the docs](https://example.com/docs)
snippet source | anchor

ReplaceText

ReplaceText(find, replacement) does an ordinal replacement across the document's text nodes.

csharp
var config = new Config();
config.Preprocess.ReplaceText("{{name}}", "Jane");

var markdown = new Converter(config).Convert("<p>Hello {{name}}, welcome back.</p>");
// Hello Jane, welcome back.
snippet source | anchor

ReplaceText(regex, replacement) takes a Regex instead.

csharp
var config = new Config();
config.Preprocess.ReplaceText(new Regex(@"\d{3}-\d{4}"), "[redacted]");

var markdown = new Converter(config).Convert("<p>Call 555-1234 today.</p>");
// Call [redacted] today.
snippet source | anchor

Both overloads skip text inside <script> and <style>, but not inside <pre>/<code>.

Stripping styles, scripts and attributes

At a glance: RemoveStyles | RemoveInlineStyles | RemoveStyleSheets | RemoveScripts | RemoveClasses | RemoveAttributes

Steps run against the whole parsed document, so these also reach the <style> and <link> elements the HTML5 parser hoists into <head>.

RemoveStyles

RemoveStyles() removes everything styling: inline style attributes, <style> elements and stylesheet <link> elements.

csharp
var config = new Config();
config.Preprocess.RemoveStyles();   // inline styles + <style> + stylesheet <link>

var html = new Converter(config).Preprocess(
    "<style>p { color: red }</style><p style=\"color:blue\">Text.</p>");
// <p>Text.</p>
snippet source | anchor

RemoveInlineStyles

RemoveInlineStyles(selector = "*") removes only inline style attributes.

csharp
var config = new Config();
config.Preprocess.RemoveInlineStyles();

var html = new Converter(config).Preprocess("<p style=\"color:red\" id=\"a\">Text.</p>");
// <p id="a">Text.</p>
snippet source | anchor

RemoveStyleSheets

RemoveStyleSheets() removes only <style> and stylesheet <link> elements.

csharp
var config = new Config();
config.Preprocess.RemoveStyleSheets();

// The HTML5 parser hoists a leading <style> into <head>; steps run against the whole
// document, so it is still reachable.
var markdown = new Converter(config).Convert("<style>p { color: red }</style><p>Body.</p>");
// Body.
snippet source | anchor

RemoveScripts

RemoveScripts() removes <script> and <noscript> elements plus inline on* event handler attributes.

csharp
var config = new Config();
config.Preprocess.RemoveScripts();   // <script>, <noscript> and on* handlers

var markdown = new Converter(config).Convert(
    "<script>track()</script><p onclick=\"go()\">Body.</p><noscript>Enable JS</noscript>");
// Body.
snippet source | anchor

RemoveClasses

RemoveClasses(selector = "*") removes class attributes.

csharp
var config = new Config();
// Spare <pre>/<code>: fenced code block languages are detected from their classes.
config.Preprocess.RemoveClasses(":not(pre):not(code)");

var html = new Converter(config).Preprocess("<p class=\"lead\">Text.</p>");
// <p>Text.</p>
snippet source | anchor

WARNING

Fenced code block languages are detected from classes such as language-cs, so pass a selector like ":not(pre):not(code)" (as above) if you strip classes and want to keep them.

RemoveAttributes

RemoveAttributes(selector, params names) removes named attributes. A name ending in * is a prefix match, so "data-*" removes all data attributes.

csharp
var config = new Config();
config.Preprocess.RemoveAttributes("*", "data-*", "id");   // "data-*" is a prefix match

// Preprocess returns the transformed HTML, which is where attributes are visible.
var html = new Converter(config).Preprocess(
    "<p id=\"lead\" data-track=\"1\" class=\"intro\">Text.</p>");
// <p class="intro">Text.</p>
snippet source | anchor

Table cells

A nested table or list inside a <td>/<th> has no Markdown representation - GitHub Flavored Markdown tables hold simple inline content only, and emitting a real list inside a cell would break the table. ReverseMarkdown therefore keeps those elements as HTML. Tables.CellListHandling decides what that HTML looks like.

RawHtml (default)

The source markup is copied verbatim, which is faithful but brings every class, inline style and editor wrapper with it. Output from CKEditor, SharePoint or Word can leave a cell looking like this:

html
<ol class="customList"><li><p class="noSpacing" data-text-type="noSpacing">
<span style="font-size:17px" data-fontsize="17px">First point</span></p></li></ol>

CleanHtml

Keeps the list as a real list, with the presentational markup stripped: class, style and data-* attributes, <span>/<font> wrappers, and a <p> that is a list item's only child. Those attributes reference a stylesheet the Markdown does not carry, and every other conversion path already drops them. A nested <table> is cleaned the same way.

csharp
// Keep the list as a real list, but drop the markup that means nothing without the
// source stylesheet.
var config = new Config
{
    Tables = { CellListHandling = Config.TableCellListHandlingOption.CleanHtml },
};

var markdown = new Converter(config).Convert(
    "<table><tr><th>Steps</th></tr><tr><td>" +
    "<ol class=\"customList\"><li><p class=\"noSpacing\">" +
    "<span style=\"font-size:17px\">Submit the request</span></p></li></ol></td></tr></table>");
// | Steps |
// | --- |
// | <ol><li>Submit the request</li></ol> |
snippet source | anchor

TIP

This is usually what you want, and it is a candidate for becoming the default in the next major version. It is opt-in for now because it changes existing output.

InlineText

Renders the list as inline text instead: one item per line separated by <br>, each prefixed with its bullet or number, with the item content converted to Markdown.

csharp
// A Markdown table cell cannot hold a real list, so by default the source HTML is kept.
// InlineText flattens it instead: one item per line, separated by <br>.
var config = new Config
{
    Tables = { CellListHandling = Config.TableCellListHandlingOption.InlineText },
};

var markdown = new Converter(config).Convert(
    "<table><tr><th>Steps</th></tr><tr><td>" +
    "<ol><li><strong>Submit</strong> the request</li><li>Wait for approval</li></ol>" +
    "</td></tr></table>");
// | Steps |
// | --- |
// | 1. **Submit** the request<br>2. Wait for approval |
snippet source | anchor

This leaves no HTML in the output at all, which suits Markdown that is read rather than rendered - RAG indexing, LLM prompts, plain-text diffing. It honours an <ol start="n"> and Formatting.ListBulletChar.

It is lossy by design: the list stops being a list, and nested lists are flattened to one level. Nested <table> elements are unaffected and stay HTML, since flattening a table to text would lose its shape entirely.

Shaping it yourself

SimplifyTableCellHtml() applies the same cleanup as CleanHtml, but as a preprocessing step over the source document. Reach for it when you want the cleanup to reach content the option does not touch, or to combine it with other steps.

csharp
var config = new Config();
config.Preprocess.SimplifyTableCellHtml();

// A list inside a table cell has no Markdown form, so it is kept as raw HTML. This trims
// that retained HTML down to its structure.
var markdown = new Converter(config).Convert(
    "<table><tr><th>Policy</th></tr><tr><td>" +
    "<ol class=\"customList\"><li><p class=\"noSpacing\">" +
    "<span style=\"font-size:17px\">First point</span></p></li>" +
    "<li><p><span style=\"font-size:17px\">Second point</span></p></li></ol>" +
    "</td></tr></table>");
// | Policy |
// | --- |
// | <ol><li>First point</li><li>Second point</li></ol> |
snippet source | anchor

For a different trade-off, scope any general helper to cells with a descendant selector:

csharp
// A different trade-off: scope any general helper to table cells with a descendant selector.
var config = new Config();
config.Preprocess
    .Unwrap("td span, th span")
    .RemoveAttributes("td *, th *", "class", "style", "data-*");

var markdown = new Converter(config).Convert(
    "<table><tr><th>Policy</th></tr><tr><td>" +
    "<ol class=\"customList\"><li><p class=\"noSpacing\">" +
    "<span style=\"font-size:17px\">First point</span></p></li></ol>" +
    "</td></tr></table>");
// | Policy |
// | --- |
// | <ol><li><p>First point</p></li></ol> |
snippet source | anchor

Which to pick. CleanHtml if the Markdown gets rendered: the list still renders as a list, without the noise. InlineText if it gets read, for Markdown with no HTML in it. Both compose with preprocessing, which runs first.

Working with styles

Formatting that only exists as CSS is lost in translation unless you recover it first. Word, Outlook and Google Docs exports are the usual offenders: they emit <span style="font-weight:700"> instead of <strong>.

At a glance: ConvertInlineStylesToTags | RemoveHidden | InlineStyle

ConvertInlineStylesToTags

ConvertInlineStylesToTags() promotes inline formatting to semantic tags: a bold font-weight becomes <strong>, an italic font-style becomes <em>, and a line-through text-decoration becomes <del>.

csharp
var config = new Config();
config.Preprocess
    .ConvertInlineStylesToTags()
    .Unwrap("span");

var markdown = new Converter(config).Convert(
    "<p><span style=\"font-weight:700\">bold</span> and " +
    "<span style=\"font-style:italic\">italic</span></p>");
// **bold** and *italic*
snippet source | anchor

It strips the declarations it consumes, so it is idempotent and leaves unrelated ones (color, margin) untouched. Pair it with Unwrap to shed the wrappers once they have done their job:

csharp
// Word, Outlook and Google Docs emit formatting as inline CSS rather than semantic tags.
var config = new Config();
config.Preprocess
    .RemoveHidden()                 // display:none / visibility:hidden / hidden
    .ConvertInlineStylesToTags()    // font-weight:700 -> <strong>, font-style:italic -> <em>
    .Unwrap("span, font");          // shed the now-meaningless wrappers

// <span style="font-weight:700">bold</span>  ->  **bold**
snippet source | anchor

RemoveHidden

RemoveHidden() drops elements hidden by an inline display: none / visibility: hidden, or by the hidden attribute. Email preheader text is the classic case.

csharp
var config = new Config();
config.Preprocess.RemoveHidden();

var markdown = new Converter(config).Convert(
    "<p style=\"display:none\">Hidden preheader.</p><p>Visible.</p><p hidden>Also hidden.</p>");
// Visible.
snippet source | anchor

The InlineStyle helper

InlineStyle exposes the same reading primitives for your own steps: Get, Has, IsBold, IsItalic, IsStruckThrough and IsHidden. It parses the style attribute directly, and correctly ignores separators inside values such as url(data:image/png;base64,...).

csharp
var config = new Config();
// InlineStyle reads the style attribute for use in your own steps.
config.Preprocess.RemoveWhere(e => InlineStyle.Get(e, "color") == "red");

var markdown = new Converter(config).Convert(
    "<p style=\"color:red\">Dropped.</p><p style=\"color:blue\">Kept.</p>");
// Kept.
snippet source | anchor

Styles from stylesheets (AngleSharp.Css)

The helpers above read the style attribute only. A rule in a <style> block or an external sheet needs the CSS cascade, which AngleSharp keeps in the separate AngleSharp.Css package. ReverseMarkdown deliberately does not depend on it: it adds no selector power (AngleSharp's core already handles :has(), :is(), :not(), :nth-child() and friends), and it introduces Activator.CreateInstance trim warnings that would end this library's Native AOT guarantee.

You can opt in yourself. Pass a browsing context configured with .WithCss() to the converter and your steps get ComputeCurrentStyle():

csharp
// AngleSharp.Css is an optional package that ReverseMarkdown does not depend on. Reference
// it yourself and hand the converter a CSS-enabled browsing context; preprocessing steps
// then get ComputeCurrentStyle(), which resolves rules from <style> blocks and not just the
// inline style attribute.
var context = BrowsingContext.New(Configuration.Default.WithCss());

var config = new Config();
config.Preprocess
    .Transform("span", span =>
    {
        if (span.ComputeCurrentStyle().GetPropertyValue("font-weight") is "bold" or "700")
        {
            span.InnerHtml = $"<strong>{span.InnerHtml}</strong>";
        }
    })
    .RemoveStyleSheets()
    .Unwrap("span");

var converter = new Converter(config, context);

var markdown = converter.Convert(
    """
    <style>.bold { font-weight: bold }</style>
    <p>plain <span class="bold">cascaded bold</span> here</p>
    """);
// plain **cascaded bold** here
snippet source | anchor

WARNING

<head> computes to display: none, and it holds the stylesheets. A predicate like RemoveWhere(e => e.ComputeCurrentStyle().GetPropertyValue("display") == "none") would therefore target <head> and destroy the cascade for every later step. The built-in removal steps never detach <html>, <head> or <body>, so this is safe, but read computed styles before you remove hidden content.

Two more caveats: AngleSharp fetches external resources if your configuration registers a requester, and a shared browsing context is not guaranteed safe for concurrent parsing.

The safe ordering, reading the cascade before removing anything:

csharp
var context = BrowsingContext.New(Configuration.Default.WithCss());

var config = new Config();
config.Preprocess
    // Read computed styles BEFORE removing anything: <head> computes to display:none and
    // holds the stylesheets, so removing hidden content first would kill the cascade for
    // later steps. The built-in removal steps never detach <html>/<head>/<body>.
    .RemoveWhere(e => e.ComputeCurrentStyle().GetPropertyValue("display") == "none")
    .RemoveStyleSheets();

var converter = new Converter(config, context);
snippet source | anchor

Rewriting URLs

ResolveRelativeUrls(baseUrl) turns relative href, src and poster values into absolute URLs, so links and images survive out of their original page. Absolute URLs (including data: and mailto:) and in-page #anchor references are left alone; protocol-relative //host/path values pick up the base scheme.

csharp
var config = new Config();
config.Preprocess.ResolveRelativeUrls("https://example.com/guide/index.html");

var markdown = new Converter(config).Convert(
    "<p><a href=\"/docs\">docs</a> and <img src=\"img/a.png\" alt=\"a\"> " +
    "and <a href=\"#top\">top</a></p>");
// [docs](https://example.com/docs) and ![a](https://example.com/guide/img/a.png) and [top](#top)
snippet source | anchor

A fuller extraction pipeline, converting just the article body of a scraped page:

csharp
// Convert just the article body of a scraped page, with relative links made absolute.
var config = new Config();
config.Preprocess
    .KeepOnly("article.post")
    .ResolveRelativeUrls("https://example.com/blog/my-post")
    .ReplaceWith("div.callout", "<blockquote>Note</blockquote>")
    .ReplaceWithText("img.emoji", ":smile:");
snippet source | anchor

Custom steps

Nothing here is a closed set. In increasing order of power: Transform for per-element work, Add(name, step) for a whole-document step that receives the <html> element, and AddText for a rewrite of the raw markup before it is parsed.

csharp
var config = new Config();

// Anything the built-ins do not cover: a per-element transform...
config.Preprocess.Transform(
    "a[href^='/']",
    a => a.SetAttribute("href", "https://example.com" + a.GetAttribute("href")));

// ...or a whole-document step, which receives the <html> element.
config.Preprocess.Add("drop-empty-tables", root =>
{
    foreach (var table in root.QuerySelectorAll("table").ToList())
    {
        if (table.QuerySelector("td, th") is null)
        {
            table.Remove();
        }
    }
});
snippet source | anchor

Text steps (before parsing)

A DOM step cannot fix markup the parser has already reinterpreted. AddText runs against the raw HTML instead, with line endings normalized to \n. All text steps run before all DOM steps, regardless of the order you add them.

csharp
config.Preprocess
    .AddText("expand-template", html => html.Replace("{{name}}", "Jane"))
    .Remove("nav");   // DOM step, still sees the expanded markup
snippet source | anchor

Prefer DOM steps for anything structural: they are safer and say what they mean. Reach for a text step only when the transformation genuinely has to happen before parsing.

For something reusable, implement IHtmlPreprocessStep and pass it to Add:

csharp
// A reusable step: implement IHtmlPreprocessStep and hand it to Add.
public sealed class UppercaseHeadings : IHtmlPreprocessStep
{
    public void Apply(IElement root)
    {
        foreach (var heading in root.QuerySelectorAll("h1, h2, h3"))
        {
            heading.TextContent = heading.TextContent.ToUpperInvariant();
        }
    }
}
snippet source | anchor
csharp
config.Preprocess.Add(new UppercaseHeadings());
snippet source | anchor

The pipeline itself is inspectable and resettable: Steps, TextSteps, Count and Clear().

Custom steps get the live AngleSharp DOM with nothing held back, so there is no transformation the pipeline can refuse. The one rule the converter enforces: leave <html>, <head> and <body> in the document (remove their contents instead). The built-in steps already honour this; a custom step that detaches them fails with an explanation rather than a NullReferenceException.

Inspecting the preprocessed HTML

Converter.Preprocess(html) returns the transformed HTML without converting it, which is handy for debugging a pipeline or caching the cleaned markup. It returns the input unchanged when no steps are configured.

csharp
var config = new Config();
config.Preprocess.RemoveScripts().Remove("nav");

// The transformed HTML, without converting it.
var cleaned = new Converter(config).Preprocess(Html);
snippet source | anchor

Notes

  • Preprocessing applies to both Convert and Parse, so the Markdown DOM you get back is built from the transformed HTML.
  • Steps run before the Html filters (Html.ExcludeSelectors and Html.ElementFilters), which stay supported and are effectively a narrower version of Remove and RemoveWhere.
  • The CommonMark and GitHub flavors pass raw HTML blocks through verbatim, and Slack rejects unsupported table markup. Both checks see the preprocessed markup, so a step that removes an offending element takes effect.
  • Configure the pipeline before converting. Custom steps and predicates must be thread-safe if the converter is shared across threads.
  • No reflection is involved, so preprocessing publishes cleanly under trimming and Native AOT.

Released under the MIT License.