Skip to content

Papyrine/OpenXmlHtml

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

199 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OpenXmlHtml

Build status NuGet Status

Converts HTML to OpenXml elements for use in xlsx and docx files.

Uses AngleSharp for HTML parsing and DocumentFormat.OpenXml for OpenXml generation.

Open Source Maintenance Fee

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.

NuGet package

https://nuget.org/packages/OpenXmlHtml/

Spreadsheet (xlsx)

SetCellHtml

Set the value of a spreadsheet cell from HTML:

var cell = new SpreadsheetCell();
SpreadsheetHtmlConverter.SetCellHtml(cell, "<b>Hello</b> <i>World</i>");

snippet source | anchor

ToInlineString

Get an InlineString for use in a cell:

var inlineString = SpreadsheetHtmlConverter.ToInlineString(
    "<b>Revenue:</b> <font color=\"#008000\">$1.2M</font>");

snippet source | anchor

Lists in Cells

var inlineString = SpreadsheetHtmlConverter.ToInlineString(
    """
    <ul>
      <li><span style="color: green">Passed</span>: 47</li>
      <li><span style="color: red">Failed</span>: 3</li>
    </ul>
    """);

snippet source | anchor

Rich Content

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

snippet source | anchor

Word (docx)

ToParagraphs

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

snippet source | anchor

AppendHtml

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

snippet source | anchor

Rich Document

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

snippet source | anchor

ConvertToDocx

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);

snippet source | anchor

ConvertStreamToDocx

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);

snippet source | anchor

ConvertFileToDocx

Convert an HTML file to a docx file:

WordHtmlConverter.ConvertFileToDocx(htmlPath, docxPath);

snippet source | anchor

Headers and Footers

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

snippet source | anchor

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.

Remote Images

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);

snippet source | anchor

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.

Shared Numbering Session

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);

snippet source | anchor

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.

Seeding List Definitions

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);

snippet source | anchor

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.

Seeding Style Definitions

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);

snippet source | anchor

It adds Normal, Heading1Heading6, 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.

CSS Class to Word Style Mapping

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);

snippet source | anchor

  • Paragraph styles (w:type="paragraph") are applied via ParagraphStyleId
  • Character styles (w:type="character") are applied via RunStyle
  • Style lookup is case-insensitive
  • Heading styles (Heading1Heading6) take precedence over CSS class styles

Diagnostics

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 equivalent

snippet source | anchor

Each 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 example width: 50% on a <col> or an <img>.
  • IgnoredAttribute — an attribute that was read but could not be applied. For example width="50%" on a <col>, or a src the ImagePolicy blocked.
  • UnsupportedElement — an element that renders in a browser but contributes nothing here. For example <iframe>, <video>, <canvas>, or an image converted without a MainDocumentPart to 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.

Supported HTML Elements

Text Formatting

  • b, strong - Bold
  • i, em, cite, dfn, var - Italic
  • u, ins - Underline
  • s, strike, del - Strikethrough
  • sub - Subscript
  • sup - Superscript
  • small - Smaller font size
  • code, kbd, samp - Monospace font (Courier New)
  • mark - Highlight (yellow background shading)
  • a - Hyperlink (underline + blue; Word: #id links become bookmarks, external URLs become clickable Word hyperlinks including images inside links)

Block Elements

  • p, div - Paragraphs / divisions (Word: id attribute 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.
  • h1 - h6 - Headings (Word: Heading1-Heading6 paragraph styles; Spreadsheet: bold)
  • blockquote - Block quotation (Word: cite attribute creates footnote)
  • pre - Preformatted text (whitespace preserved)
  • hr - Horizontal rule
  • article, aside, section, header, footer, nav, main - Semantic blocks

Lists

  • ul, ol, li - Unordered (bullet) and ordered (numbered) lists
    • Word (via ToElements/ConvertToDocx): real NumberingDefinitionsPart with ListParagraph style — 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
  • type attribute on <ol>: 1 (decimal), a (lower-alpha), A (upper-alpha), i (lower-roman), I (upper-roman) (Word)
  • start attribute on <ol>: starting number (e.g., <ol start="5">) (Word)
  • list-style-type CSS: decimal, lower-alpha/lower-latin, upper-alpha/upper-latin, lower-roman, upper-roman (Word)
  • reversed attribute 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

Tables

  • table, tr, td, th - Table structure
    • Word: real Table/TableRow/TableCell elements with borders
    • Spreadsheet: tab-separated cells, newline-separated rows
  • colspan, rowspan - Cell spanning (Word)
  • thead, tbody, tfoot - Table sections. Every thead row 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 a thead written after a tbody has no effect
  • caption - 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 as px) or a percentage, which maps to w:type="pct"
  • Nested tables supported (Word)
  • Cell CSS: padding, width, background-color, vertical-align (top/middle/bottom) (Word). width accepts the same units as the attribute above
  • Table CSS: width, background-color, padding (default cell padding) (Word). An absolute table width is 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: height or HTML height attribute on <tr> (Word)
  • col / colgroup widths must be absolute — Word's w:gridCol has 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.

Inline / Other

  • br - Line break within the current paragraph (Word: <w:br/>; Spreadsheet: a newline inside the cell). Contrast hr, which ends the paragraph. Whitespace immediately after a br is dropped, as a browser drops it
  • span - Generic inline container
  • font - Font styling (color, size, face attributes)
  • time - Time element
  • abbr, acronym - Abbreviations (Word: title attribute creates footnote)
  • q - Inline quotation (smart quotes)
  • img - Image (base64 data URIs always embedded; HTTP/local images via HtmlConvertSettings; alt text fallback)
  • figure, figcaption - Figure and caption
  • svg - Inline SVG (embedded as image in Word with PNG fallback)
  • dl, dt, dd - Definition list (dt is bold)

CSS Style Attribute

Inline style attributes are supported:

  • font-weight: bold (or 700-900)
  • font-style: italic
  • text-decoration: underline, text-decoration: line-through
  • text-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: none or HTML border="0" removes default table borders
  • color - Text color (hex, named, rgb())
  • font-size - Font size (pt, px, em, keywords)
  • font-family - Font family
  • vertical-align: super, vertical-align: sub
  • page-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 cells
  • font-variant: small-caps - Small capitals (Word)
  • text-shadow - Text shadow (Word); any value other than none turns it on
  • text-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-wrap and nowrap; under the default normal, 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.

CSS Class Attribute

The class attribute maps CSS class names to Word styles defined in the document's StyleDefinitionsPart. See CSS Class to Word Style Mapping above.

Color Formats

  • 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)

Why Not altChunk?

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.

Icon

https://thenounproject.com/icon/dog-6292156/

About

Converts HTML to OpenXml elements for use in xlsx and docx files.

Resources

Stars

Watchers

Forks

Releases

Used by

Contributors

Languages