Converts HTML to OpenXml elements for use in xlsx and docx files.
Uses AngleSharp for HTML parsing and DocumentFormat.OpenXml for OpenXml generation.
This project participates in the Open Source Maintenance Fee. The source code is freely available under the terms of the license. To support sustainable maintenance, use of the project's official binary releases in revenue-generating activities and all government agencies requires adherence to the Open Source Maintenance Fee EULA. The fee is paid by sponsoring Papyrine.
This project uses SponsorCheck to surface a build-time reminder in consuming projects that are not yet sponsoring.
https://nuget.org/packages/OpenXmlHtml/
Set the value of a spreadsheet cell from HTML:
var cell = new SpreadsheetCell();
SpreadsheetHtmlConverter.SetCellHtml(cell, "<b>Hello</b> <i>World</i>");Get an InlineString for use in a cell:
var inlineString = SpreadsheetHtmlConverter.ToInlineString(
"<b>Revenue:</b> <font color=\"#008000\">$1.2M</font>");var inlineString = SpreadsheetHtmlConverter.ToInlineString(
"""
<ul>
<li><span style="color: green">Passed</span>: 47</li>
<li><span style="color: red">Failed</span>: 3</li>
</ul>
""");var cell = new SpreadsheetCell();
SpreadsheetHtmlConverter.SetCellHtml(
cell,
"""
<h2>Q1 Report</h2>
<p>Revenue: <b style="color: green">$1.2M</b></p>
<p>See <a href="https://example.com/report">full report</a></p>
<table>
<tr><th>Region</th><th>Sales</th></tr>
<tr><td>North</td><td>$500K</td></tr>
<tr><td>South</td><td>$700K</td></tr>
</table>
""");Convert HTML to a list of Paragraph elements:
var paragraphs = WordHtmlConverter.ToParagraphs(
"""
<h1>Report Title</h1>
<p>This is a <b>bold</b> statement with <i>emphasis</i>.</p>
""");Append HTML content directly to a Word document body:
using var stream = new MemoryStream();
using var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document);
var main = document.AddMainDocumentPart();
main.Document = new(new Body());
WordHtmlConverter.AppendHtml(
main.Document.Body!,
"""
<h1>Meeting Notes</h1>
<p><i>Date: January 15, 2024</i></p>
<ol>
<li>Review <code>PR #123</code></li>
<li>Update <u>documentation</u></li>
</ol>
""");var paragraphs = WordHtmlConverter.ToParagraphs(
"""
<h2>Status Report</h2>
<p>All systems <span style="color: green"><b>operational</b></span>.</p>
<ul>
<li>Server: <span style="color: green">OK</span></li>
<li>Cache: <span style="color: red">Down</span></li>
</ul>
<p>Contact <a href="mailto:ops@example.com">ops team</a> for details.</p>
""");Convert an HTML string directly to a docx file:
using var stream = new MemoryStream();
WordHtmlConverter.ConvertToDocx(
"""
<h1>Report</h1>
<p>This is a <b>bold</b> statement.</p>
<ul>
<li>Item one</li>
<li>Item two</li>
</ul>
""",
stream);Convert an HTML stream to a docx stream:
using var htmlStream = new MemoryStream(
"<h1>Report</h1><p>Content</p>"u8.ToArray());
using var docxStream = new MemoryStream();
WordHtmlConverter.ConvertToDocx(htmlStream, docxStream);Convert an HTML file to a docx file:
WordHtmlConverter.ConvertFileToDocx(htmlPath, docxPath);Set document headers and footers from HTML:
using var headerStream = new MemoryStream();
using var headerDoc = WordprocessingDocument.Create(
headerStream, WordprocessingDocumentType.Document);
var headerMainPart = headerDoc.AddMainDocumentPart();
headerMainPart.Document = new(new Body());
WordHtmlConverter.AppendHtml(
headerMainPart.Document.Body!,
"<p>Document content</p>",
headerMainPart);
WordHtmlConverter.SetHeader(headerMainPart,
"""<p style="text-align: center"><b>Company Name</b></p>""");
WordHtmlConverter.SetFooter(headerMainPart,
"""<p style="text-align: center; font-size: 9pt; color: gray">Confidential</p>""");Headers and footers support all the same HTML elements and CSS properties as the document body (tables, formatting, images, etc.). Overloads accepting HtmlConvertSettings are also available.
By default, only base64 data URI images are embedded. External images (HTTP/HTTPS URLs, local files) require explicit opt-in via HtmlConvertSettings and ImagePolicy:
// Configure which image sources are allowed
var settings = new HtmlConvertSettings
{
WebImages = ImagePolicy.SafeDomains("cdn.example.com"),
LocalImages = ImagePolicy.SafeDirectories(@"C:\Reports\Images")
};
// Pass settings to any conversion method
using var settingsStream = new MemoryStream();
WordHtmlConverter.ConvertToDocx(
"""
<h1>Report</h1>
<p><img src="https://cdn.example.com/logo.png" alt="Logo"></p>
""",
settingsStream,
settings);Available policies:
ImagePolicy.Deny()— Default. Rejects all remote/local images.ImagePolicy.AllowAll()— Allows any source.ImagePolicy.SafeDomains("example.com", ...)— Allows web images from specified domains (exact or subdomain match).ImagePolicy.SafeDirectories(@"C:\Images", ...)— Allows local images from specified directories (path traversal protected).ImagePolicy.Filter(src => ...)— Custom predicate.
All conversion methods (ToParagraphs, ToElements, AppendHtml, ConvertToDocx, ConvertFileToDocx) and their spreadsheet equivalents accept an HtmlConvertSettings parameter. A custom HttpClient can be provided via HtmlConvertSettings.HttpClient.
When calling ToElements multiple times to build a single Word document, each call normally creates its own bullet abstract numbering definition. Passing the same HtmlNumberingSession via HtmlConvertSettings.NumberingSession makes all calls share one definition:
var session = new HtmlNumberingSession();
var settings = new HtmlConvertSettings
{
NumberingSession = session
};
// Both fragments reuse one bullet abstract — one definition, two numbering instances.
var first = WordHtmlConverter.ToElements("<ul><li>a</li><li>b</li></ul>", mainPart, settings);
var second = WordHtmlConverter.ToElements("<ul><li>c</li><li>d</li></ul>", mainPart, settings);Without a session each ToElements call allocates its own abstract, which is harmless but produces redundant definitions. A session is only needed when you call ToElements several times against the same MainDocumentPart within one render.
A numbering part is created only when the converted HTML actually contains a list. That is usually what you want, but it leaves a protected document unable to accept one: applying a bullet in Word writes a definition to word/numbering.xml, a document-level part, and under w:documentProtection w:edit="readOnly" that part sits outside every editable range. Word responds by disabling the bullet and numbering commands — even inside a rich-text content control the user is otherwise allowed to edit.
WordNumbering.EnsureListDefinitions seeds the definitions up front, so Word only has to reference them:
// A bullet and a decimal definition for Word to reference, so it never has to create one.
WordNumbering.EnsureListDefinitions(mainPart);It adds one bullet and one decimal definition, each with a numbering instance (Word cannot apply an abstract definition that no instance points at). It is idempotent and safe to call either side of a conversion: a format already present is left alone, and new ids continue past the highest in use. The part is created with a deterministic relationship id, so output stays byte-reproducible.
Seed only when the document is protected and users are expected to edit rich-text regions. An unprotected document needs nothing — Word will create definitions on demand.
The same problem applies to named paragraph styles. Applying Heading 1 (or any style) in Word writes a definition to word/styles.xml, another document-level part that w:documentProtection w:edit="readOnly" locks. So the style gallery and the Heading buttons are greyed out inside an editable rich-text region unless the definitions already exist.
WordStyles.EnsureStyleDefinitions seeds them:
// Normal + Heading1-6 + ListParagraph, so Word's style gallery is available.
WordStyles.EnsureStyleDefinitions(mainPart);It adds Normal, Heading1–Heading6, and ListParagraph with their built-in w:styleIds (Word links its Heading buttons to those ids, so they must be exact). It is idempotent — a style already present by id is left untouched, so a template's own definitions win — and the part is created with a deterministic relationship id, so output stays byte-reproducible.
Pair it with EnsureListDefinitions when a protected document exposes a rich-text region: numbering enables the list buttons, styles enable the gallery.
When converting with a MainDocumentPart that has a StyleDefinitionsPart, CSS class names are matched against Word style definitions:
// CSS class names are matched against Word styles in the document.
// Paragraph styles apply via ParagraphStyleId,
// character styles apply via RunStyle.
WordHtmlConverter.AppendHtml(
styleBody,
"""
<p class="Quote">This uses the Quote paragraph style</p>
<p>Normal text with <span class="Emphasis">emphasized</span> word</p>
""",
styleMainPart);- Paragraph styles (
w:type="paragraph") are applied viaParagraphStyleId - Character styles (
w:type="character") are applied viaRunStyle - Style lookup is case-insensitive
- Heading styles (
Heading1–Heading6) take precedence over CSS class styles
Some HTML cannot be carried into a Word document. A percentage column width has no form in w:gridCol, an <iframe> has no Word equivalent, a blocked image source resolves to nothing. Conversion still succeeds and still produces a valid document — it is just missing something the author wrote, with no signal that anything was lost.
HtmlConvertSettings.OnDiagnostic is that signal:
var dropped = new List<HtmlDiagnostic>();
var settings = new HtmlConvertSettings
{
OnDiagnostic = dropped.Add
};
using var diagnosticStream = new MemoryStream();
WordHtmlConverter.ConvertToDocx(
"""
<table>
<col width="40%">
<tr><td>Cell</td></tr>
</table>
<iframe src="https://example.com"></iframe>
""",
diagnosticStream,
settings);
// IgnoredAttribute, width, "40%", w:gridCol takes an absolute width, ...
// UnsupportedElement, iframe, no Word equivalentEach HtmlDiagnostic carries a Kind, the Name of the property, attribute, or element, the Value that was discarded, and a Reason:
DroppedProperty— a CSS declaration that was read but has no form in the output. For examplewidth: 50%on a<col>or an<img>.IgnoredAttribute— an attribute that was read but could not be applied. For examplewidth="50%"on a<col>, or asrctheImagePolicyblocked.UnsupportedElement— an element that renders in a browser but contributes nothing here. For example<iframe>,<video>,<canvas>, or an image converted without aMainDocumentPartto hold it.
Only deliberate drops are reported — places where the converter read the input, understood it, and could not express it. Unknown CSS is left alone, since reporting every cursor and float an ordinary stylesheet carries would bury the signal. Markup that is fully supported reports nothing, which makes the useful test-time assertion "this document dropped nothing" possible.
Reporting is opt-in and costs nothing while OnDiagnostic is null. The callback runs on the converting thread as each drop happens.
b,strong- Boldi,em,cite,dfn,var- Italicu,ins- Underlines,strike,del- Strikethroughsub- Subscriptsup- Superscriptsmall- Smaller font sizecode,kbd,samp- Monospace font (Courier New)mark- Highlight (yellow background shading)a- Hyperlink (underline + blue; Word:#idlinks become bookmarks, external URLs become clickable Word hyperlinks including images inside links)
p,div- Paragraphs / divisions (Word:idattribute creates bookmark)- An explicitly empty text block (
<p></p>,<div></div>,<h1></h1>) is a blank line, and an empty paragraph is its only representation in Word, so one is emitted. Any paragraph properties on it (style, alignment, spacing) are kept. Container blocks are excluded — an empty<ul>,<table>or<section>means "no content", not "a blank line". A trailing bare empty paragraph is still trimmed, so a fragment never leaves a dangling blank line behind it.
- An explicitly empty text block (
h1-h6- Headings (Word: Heading1-Heading6 paragraph styles; Spreadsheet: bold)blockquote- Block quotation (Word:citeattribute creates footnote)pre- Preformatted text (whitespace preserved)hr- Horizontal rulearticle,aside,section,header,footer,nav,main- Semantic blocks
ul,ol,li- Unordered (bullet) and ordered (numbered) lists- Word (via
ToElements/ConvertToDocx): realNumberingDefinitionsPartwithListParagraphstyle — proper bullets and numbering rendered by Word, supporting nested lists and separate numbering sequences - Word (via
ToParagraphs): text prefix fallback (●/○/■ for bullets, 1./2./3. for ordered) - Spreadsheet: text prefix bullets and numbers
- Word (via
typeattribute on<ol>:1(decimal),a(lower-alpha),A(upper-alpha),i(lower-roman),I(upper-roman) (Word)startattribute on<ol>: starting number (e.g.,<ol start="5">) (Word)list-style-typeCSS:decimal,lower-alpha/lower-latin,upper-alpha/upper-latin,lower-roman,upper-roman(Word)reversedattribute on<ol>: countdown numbering (Word, via text prefix fallback)- A block child opening an item continues that item's line rather than starting one after it, so
<li><p>x</p></li>is a single marked item — html from a source that wraps everything in<p>still reads as a list. Later children of the same item do start their own paragraphs, matching a browser putting only the first line alongside the marker
table,tr,td,th- Table structure- Word: real
Table/TableRow/TableCellelements with borders - Spreadsheet: tab-separated cells, newline-separated rows
- Word: real
colspan,rowspan- Cell spanning (Word)thead,tbody,tfoot- Table sections. Everytheadrow is marked as a repeating header (w:tblHeader), so a table broken across a page break keeps its header on each later page. Word repeats only rows that lead the table, so atheadwritten after atbodyhas no effectcaption- Table caption (bold)cellpadding- HTML attribute for default cell padding (Word)bgcolor- HTML attribute for cell background color (Word)width- HTML attribute for cell width (Word). Absolute (px,pt,in,cm,mm, or a bare number treated aspx) or a percentage, which maps tow:type="pct"- Nested tables supported (Word)
- Cell CSS:
padding,width,background-color,vertical-align(top/middle/bottom) (Word).widthaccepts the same units as the attribute above - Table CSS:
width,background-color,padding(default cell padding) (Word). An absolute tablewidthis shared across the columns and switches the table to fixed layout, since Word's default autofit treats the width as a preference and resizes columns to their content. The share only applies when the cells give no widths of their own - Row CSS:
heightor HTMLheightattribute on<tr>(Word) col/colgroupwidths must be absolute — Word'sw:gridColhas no percentage unit, so a percentage there is ignored
Word lays a table out from w:tblGrid, not from the cells, so column widths have to reach the grid
to have any effect. They are taken from col/colgroup where it is present, and otherwise from the
first row that maps one cell to one column — a row carrying a colspan or rowspan is skipped as
ambiguous, and so is one where only some cells give a width, since half a row cannot lay out a
table. Percentage cell widths still apply to the cells themselves but cannot fill the grid, for the
same reason col percentages are ignored.
br- Line break within the current paragraph (Word:<w:br/>; Spreadsheet: a newline inside the cell). Contrasthr, which ends the paragraph. Whitespace immediately after abris dropped, as a browser drops itspan- Generic inline containerfont- Font styling (color, size, face attributes)time- Time elementabbr,acronym- Abbreviations (Word:titleattribute creates footnote)q- Inline quotation (smart quotes)img- Image (base64 data URIs always embedded; HTTP/local images viaHtmlConvertSettings; alt text fallback)figure,figcaption- Figure and captionsvg- Inline SVG (embedded as image in Word with PNG fallback)dl,dt,dd- Definition list (dt is bold)
Inline style attributes are supported:
font-weight: bold(or 700-900)font-style: italictext-decoration: underline,text-decoration: line-throughtext-decoration-style- Underline variant:dotted,dashed,wavy,double(Word)border- Border on runs, paragraphs, table cells, and tables (Word)- Shorthand:
border: 1px solid black - Per-side:
border-top,border-right,border-bottom,border-left - Styles:
solid,dotted,dashed,double,groove,ridge,inset,outset,none - Table:
border: noneor HTMLborder="0"removes default table borders
- Shorthand:
color- Text color (hex, named, rgb())font-size- Font size (pt, px, em, keywords)font-family- Font familyvertical-align: super,vertical-align: subpage-break-before: always- Page break before element (Word)page-break-after: always- Page break after element (Word)text-align- Paragraph alignment: left, center, right, justify (Word)margin- Paragraph spacing and indentation (Word, supports shorthand and individual sides)margin-top,margin-bottom- Paragraph spacing before/after (Word)margin-left,margin-right- Paragraph indentation (Word)text-indent- First line indent or hanging indent (Word)line-height- Line spacing: numeric multiple (1.5), percentage (150%), or fixed length (18pt) (Word)background-color- Background shading on runs and paragraphs (Word); also on table cellsfont-variant: small-caps- Small capitals (Word)text-shadow- Text shadow (Word); any value other thannoneturns it ontext-transform- Transform text:uppercase,lowercase,capitalize(Word)writing-mode- Text direction:vertical-rl,vertical-lr(Word, on paragraphs and table cells)direction: rtl- Right-to-left text direction (Word)white-space-pre,pre-wrapandnowrap; under the defaultnormal, runs of whitespace fold to a single space the way a browser folds them
CSS length units supported: pt, px, em, in, cm, mm.
A page break is written as w:pageBreakBefore on the paragraph it breaks before — the element's own
paragraph for page-break-before, the following one for page-break-after, since Word has no "break
after". It is not given a paragraph of its own: that would leave a blank line at the top of the new
page, and renderers collapse an empty paragraph and lose the break with it. The exceptions are an
empty element, where a paragraph of its own is the whole point, and a break landing on a table, which
has no w:pageBreakBefore of its own and so takes an empty paragraph ahead of it.
white-space: pre is also how a Word tab is reached from html. Under the default folding rules a tab
is whitespace like any other and collapses into the space around it, so a preserved one is the only
kind that survives. Those become <w:tab/> elements rather than tab characters, which Word ignores
inside <w:t>.
font-weight, font-style, font-variant and text-shadow all inherit in CSS, so their off values
— normal, normal, normal and none — are emitted as explicit overrides (<w:b w:val="false"/>
and equivalents) rather than by leaving the element out. Omitting it would inherit instead of
cancelling, which matters most against a paragraph style: <h3> carries bold, so
<span style="font-weight: normal"> inside one only renders unbolded because the override is
explicit. As a result a run whose only styling is an off value still carries run properties.
text-decoration is deliberately not in that group. It propagates to descendants and cannot be
cancelled by a descendant's text-decoration: none, so absent-means-off is the correct model.
The class attribute maps CSS class names to Word styles defined in the document's StyleDefinitionsPart. See CSS Class to Word Style Mapping above.
- Hex:
#RGB,#RRGGBB,#RRGGBBAA - Named: all 148 CSS named colors (
red,blue,cornflowerblue,rebeccapurple, etc.) - RGB:
rgb(255, 0, 0) - RGBA:
rgba(255, 0, 0, 0.5)(alpha channel is parsed but not applied — Word doesn't support color transparency)
Word's altChunk (w:altChunk) mechanism can embed raw HTML inside a .docx file and have Word convert it at open time. This approach was considered and rejected for several reasons:
- Deferred and lossy conversion — Word processes the HTML when the file is opened, replacing the altChunk with its own interpretation. The output varies by Word version and is not round-trip stable.
- Undocumented tag support — Microsoft has never published a conformance spec for the HTML subset that Word's import filter accepts. Supported tags are determined empirically, and behaviour differs across versions.
- Poor ecosystem compatibility — Many .docx consumers (LibreOffice, headless processors, other libraries) have partial or broken altChunk support. Files are not reliably portable.
- No programmatic access — Because the HTML is opaque to the container, the content cannot be inspected, modified, or processed without first opening the file in Word.
OpenXmlHtml instead parses HTML with AngleSharp and emits native OOXML elements directly. This means the output is valid, stable, portable OOXML from the moment it is written — with no dependency on Word being installed, no conversion step, and predictable behaviour across all .docx consumers.