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 -> MarkdownQuick start
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);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.
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.RemoveWhere
RemoveWhere(predicate) removes elements for which the predicate returns true, for conditions a CSS selector cannot express.
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.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.
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.RemoveComments
RemoveComments() drops HTML comments from the source. Unlike Formatting.RemoveComments, which works on the conversion output, this removes them before anything else runs.
var config = new Config();
config.Preprocess.RemoveComments();
var markdown = new Converter(config).Convert("<p>Visible<!-- editor note --> text.</p>");
// Visible text.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.
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.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.
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.Unwrap
Unwrap(selector) removes the element but keeps its children. Unwrap("span, font") strips styling wrappers; Unwrap("a") keeps link text without the link.
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* dWrap
Wrap(selector, tagName) wraps matching elements in a new element.
var config = new Config();
config.Preprocess.Wrap("p.note", "blockquote");
var markdown = new Converter(config).Convert("<p class=\"note\">Remember this.</p>");
// > Remember this.ReplaceWith
ReplaceWith(selector, html) replaces the element and its content with an HTML fragment.
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.
//
// > NoteReplaceWithText
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.
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.Transform
Transform(selector, action) runs your action against every match: the escape hatch for rewriting attributes, moving nodes and anything else.
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)ReplaceText
ReplaceText(find, replacement) does an ordinal replacement across the document's text nodes.
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.ReplaceText(regex, replacement) takes a Regex instead.
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.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.
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>RemoveInlineStyles
RemoveInlineStyles(selector = "*") removes only inline style attributes.
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>RemoveStyleSheets
RemoveStyleSheets() removes only <style> and stylesheet <link> elements.
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.RemoveScripts
RemoveScripts() removes <script> and <noscript> elements plus inline on* event handler attributes.
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.RemoveClasses
RemoveClasses(selector = "*") removes class attributes.
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>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.
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>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:
<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.
// 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> |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.
// 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 |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.
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> |For a different trade-off, scope any general helper to cells with a descendant selector:
// 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> |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>.
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*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:
// 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**RemoveHidden
RemoveHidden() drops elements hidden by an inline display: none / visibility: hidden, or by the hidden attribute. Email preheader text is the classic case.
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.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,...).
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.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():
// 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** hereWARNING
<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:
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);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.
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  and [top](#top)A fuller extraction pipeline, converting just the article body of a scraped page:
// 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:");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.
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();
}
}
});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.
config.Preprocess
.AddText("expand-template", html => html.Replace("{{name}}", "Jane"))
.Remove("nav"); // DOM step, still sees the expanded markupPrefer 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:
// 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();
}
}
}config.Preprocess.Add(new UppercaseHeadings());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.
var config = new Config();
config.Preprocess.RemoveScripts().Remove("nav");
// The transformed HTML, without converting it.
var cleaned = new Converter(config).Preprocess(Html);Notes
- Preprocessing applies to both
ConvertandParse, so the Markdown DOM you get back is built from the transformed HTML. - Steps run before the
Htmlfilters (Html.ExcludeSelectorsandHtml.ElementFilters), which stay supported and are effectively a narrower version ofRemoveandRemoveWhere. - 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.
