Tracked Changes HTML Rendering - Design Document

December 1, 2025 · View on GitHub

Executive Summary

This document outlines a design for extending WmlToHtmlConverter to support rendering tracked changes (revisions) in the HTML output, rather than silently accepting them before conversion.

Current Behavior

The current WmlToHtmlConverter.ConvertToHtml() method (line 169) calls:

RevisionAccepter.AcceptRevisions(wordDoc);

This removes all tracked changes before conversion, meaning:

  • Inserted text is kept, but the insertion marking is lost
  • Deleted text is completely removed
  • Move operations are flattened
  • Property changes (formatting, paragraph, table) are accepted

Revision Elements in OOXML

Content Revisions

ElementDescriptionContent
w:insInserted contentContains w:r runs with w:t text
w:delDeleted contentContains w:r runs with w:delText text
w:moveFromSource of moved contentContains runs (like w:del)
w:moveToDestination of moved contentContains runs (like w:ins)

Paragraph/Run Mark Revisions

ElementLocationMeaning
w:insw:pPr/w:rPr/w:insParagraph mark was inserted
w:delw:pPr/w:rPr/w:delParagraph mark was deleted

Table Revisions

ElementLocationMeaning
w:ins/w:delw:trPrRow inserted/deleted
w:cellInsw:tcPrCell inserted
w:cellDelw:tcPrCell deleted
w:cellMergew:tcPrCell merged

Property Change Revisions

ElementDescription
w:rPrChangeRun formatting change
w:pPrChangeParagraph formatting change
w:sectPrChangeSection formatting change
w:tblPrChangeTable formatting change
w:trPrChangeTable row formatting change
w:tcPrChangeTable cell formatting change
w:tblGridChangeTable grid change

Common Attributes

All revision elements have:

  • w:id - Unique revision ID
  • w:author - Author name
  • w:date - ISO 8601 timestamp

Proposed Design

1. Settings Extension

Add new properties to WmlToHtmlConverterSettings:

public class WmlToHtmlConverterSettings
{
    // ... existing properties ...

    /// <summary>
    /// If true, render tracked changes visually in HTML output.
    /// If false (default), accept all revisions before conversion.
    /// </summary>
    public bool RenderTrackedChanges;

    /// <summary>
    /// CSS class prefix for revision elements (default: "rev-")
    /// </summary>
    public string RevisionCssClassPrefix;

    /// <summary>
    /// If true, include revision metadata (author, date) as data attributes
    /// </summary>
    public bool IncludeRevisionMetadata;

    /// <summary>
    /// Custom colors for different authors (author name -> CSS color)
    /// </summary>
    public Dictionary<string, string> AuthorColors;

    /// <summary>
    /// If true, show deleted content with strikethrough (default: true)
    /// If false, hide deleted content but mark its location
    /// </summary>
    public bool ShowDeletedContent;

    /// <summary>
    /// If true, render move operations as separate from/to
    /// If false, render moves as delete + insert
    /// </summary>
    public bool RenderMoveOperations;
}

2. HTML Output Format

Insertions

<ins class="rev-ins" data-author="John Doe" data-date="2024-01-15T10:30:00Z">
  <span>inserted text</span>
</ins>

Deletions

<del class="rev-del" data-author="Jane Smith" data-date="2024-01-14T09:00:00Z">
  <span>deleted text</span>
</del>

Move Operations (if RenderMoveOperations is true)

<!-- Move source -->
<del class="rev-move-from" data-move-id="move123" data-author="..." data-date="...">
  <span>moved text</span>
</del>

<!-- Move destination -->
<ins class="rev-move-to" data-move-id="move123" data-author="..." data-date="...">
  <span>moved text</span>
</ins>

Paragraph Mark Changes

<!-- Deleted paragraph mark (paragraphs were merged) -->
<p class="rev-para-del" data-author="..." data-date="...">
  content...
  <span class="rev-para-mark-del" title="Paragraph mark deleted">¶</span>
</p>

<!-- Inserted paragraph (split from another) -->
<p class="rev-para-ins" data-author="..." data-date="...">
  content...
</p>

Table Row Changes

<tr class="rev-row-ins" data-author="..." data-date="...">
  <td>...</td>
</tr>

<tr class="rev-row-del" data-author="..." data-date="...">
  <td>...</td>
</tr>

Formatting Changes

<span class="rev-format-change"
      data-author="..."
      data-date="..."
      data-change-type="rPrChange"
      title="Format changed: Bold added">
  formatted text
</span>

3. Default CSS

Generate appropriate CSS in the <style> element:

/* Insertions */
ins.rev-ins {
  text-decoration: underline;
  color: #006400; /* dark green */
  background-color: #e6ffe6;
}

/* Deletions */
del.rev-del {
  text-decoration: line-through;
  color: #8b0000; /* dark red */
  background-color: #ffe6e6;
}

/* Move source */
del.rev-move-from {
  text-decoration: line-through;
  color: #4b0082; /* indigo */
  background-color: #f0e6ff;
}

/* Move destination */
ins.rev-move-to {
  text-decoration: underline;
  color: #4b0082;
  background-color: #e6f0ff;
}

/* Table row changes */
tr.rev-row-ins {
  background-color: #e6ffe6;
}

tr.rev-row-del {
  background-color: #ffe6e6;
  text-decoration: line-through;
}

/* Paragraph mark indicator */
.rev-para-mark-del {
  color: #8b0000;
  font-size: 0.8em;
  vertical-align: super;
}

/* Format changes */
.rev-format-change {
  border-bottom: 2px dotted #ffa500;
}

4. Implementation Changes

4.1. Entry Point Modification

public static XElement ConvertToHtml(WordprocessingDocument wordDoc,
    WmlToHtmlConverterSettings htmlConverterSettings)
{
    // Only accept revisions if NOT rendering tracked changes
    if (!htmlConverterSettings.RenderTrackedChanges)
    {
        RevisionAccepter.AcceptRevisions(wordDoc);
    }

    // ... rest of existing code ...
}

4.2. New Handler Methods

Add handlers in ConvertToHtmlTransform():

// Handle w:ins (inserted content)
if (element.Name == W.ins)
{
    return ProcessInsertion(wordDoc, settings, element, currentMarginLeft);
}

// Handle w:del (deleted content)
if (element.Name == W.del)
{
    return ProcessDeletion(wordDoc, settings, element, currentMarginLeft);
}

// Handle w:moveFrom
if (element.Name == W.moveFrom)
{
    return ProcessMoveFrom(wordDoc, settings, element, currentMarginLeft);
}

// Handle w:moveTo
if (element.Name == W.moveTo)
{
    return ProcessMoveTo(wordDoc, settings, element, currentMarginLeft);
}

4.3. ProcessInsertion Implementation

private static object ProcessInsertion(WordprocessingDocument wordDoc,
    WmlToHtmlConverterSettings settings, XElement element, decimal currentMarginLeft)
{
    if (!settings.RenderTrackedChanges)
    {
        // Fall through to process children normally
        return element.Elements()
            .Select(e => ConvertToHtmlTransform(wordDoc, settings, e, false, currentMarginLeft));
    }

    var ins = new XElement(Xhtml.ins);

    // Add CSS class
    var className = (settings.RevisionCssClassPrefix ?? "rev-") + "ins";
    ins.Add(new XAttribute("class", className));

    // Add metadata if requested
    if (settings.IncludeRevisionMetadata)
    {
        var author = (string)element.Attribute(W.author);
        var date = (string)element.Attribute(W.date);

        if (author != null)
            ins.Add(new XAttribute("data-author", author));
        if (date != null)
            ins.Add(new XAttribute("data-date", date));
    }

    // Process children
    ins.Add(element.Elements()
        .Select(e => ConvertToHtmlTransform(wordDoc, settings, e, false, currentMarginLeft)));

    return ins;
}

4.4. ProcessDeletion Implementation

private static object ProcessDeletion(WordprocessingDocument wordDoc,
    WmlToHtmlConverterSettings settings, XElement element, decimal currentMarginLeft)
{
    if (!settings.RenderTrackedChanges)
    {
        // When not rendering tracked changes, deletions are removed
        return null;
    }

    if (!settings.ShowDeletedContent)
    {
        // Show marker but not content
        return new XElement(Xhtml.span,
            new XAttribute("class", (settings.RevisionCssClassPrefix ?? "rev-") + "del-marker"),
            new XAttribute("title", "Deleted content"));
    }

    var del = new XElement(Xhtml.del);

    // Add CSS class
    var className = (settings.RevisionCssClassPrefix ?? "rev-") + "del";
    del.Add(new XAttribute("class", className));

    // Add metadata if requested
    if (settings.IncludeRevisionMetadata)
    {
        var author = (string)element.Attribute(W.author);
        var date = (string)element.Attribute(W.date);

        if (author != null)
            del.Add(new XAttribute("data-author", author));
        if (date != null)
            del.Add(new XAttribute("data-date", date));
    }

    // Process children - note: w:del contains w:delText instead of w:t
    del.Add(ProcessDeletedContent(wordDoc, settings, element, currentMarginLeft));

    return del;
}

4.5. Handle w:delText

// Transform every w:delText element to a text node (only when rendering revisions)
if (element.Name == W.delText && settings.RenderTrackedChanges)
{
    return new XText(element.Value);
}

4.6. Paragraph Mark Revisions

Modify ProcessParagraph to check for w:pPr/w:rPr/w:ins or w:del:

private static object ProcessParagraph(...)
{
    // ... existing code ...

    if (settings.RenderTrackedChanges)
    {
        var pPr = element.Element(W.pPr);
        var rPr = pPr?.Element(W.rPr);

        var paraIns = rPr?.Element(W.ins);
        var paraDel = rPr?.Element(W.del);

        if (paraIns != null)
        {
            // This paragraph mark was inserted (paragraph was split)
            style.Add("--rev-para-ins", "true");
            // Add appropriate class to paragraph element
        }

        if (paraDel != null)
        {
            // This paragraph mark was deleted (paragraphs were merged)
            // Add pilcrow marker at end of paragraph
        }
    }

    // ... rest of existing code ...
}

4.7. Table Row Revisions

Modify ProcessTableRow to handle w:trPr/w:ins and w:trPr/w:del:

private static object ProcessTableRow(...)
{
    var style = new Dictionary<string, string>();

    if (settings.RenderTrackedChanges)
    {
        var trPr = element.Element(W.trPr);
        var rowIns = trPr?.Element(W.ins);
        var rowDel = trPr?.Element(W.del);

        if (rowIns != null || rowDel != null)
        {
            // Add class to row
        }
    }

    // ... rest of existing code ...
}

5. CSS Generation

Add revision CSS to ReifyStylesAndClasses:

private static void ReifyStylesAndClasses(...)
{
    // ... existing code ...

    if (htmlConverterSettings.RenderTrackedChanges)
    {
        sb.Append(GenerateRevisionCss(htmlConverterSettings));
    }

    // ... rest of existing code ...
}

private static string GenerateRevisionCss(WmlToHtmlConverterSettings settings)
{
    var prefix = settings.RevisionCssClassPrefix ?? "rev-";
    var sb = new StringBuilder();

    sb.AppendLine($"ins.{prefix}ins {{ text-decoration: underline; color: #006400; }}");
    sb.AppendLine($"del.{prefix}del {{ text-decoration: line-through; color: #8b0000; }}");
    // ... more CSS ...

    // Author-specific colors
    if (settings.AuthorColors != null)
    {
        foreach (var kvp in settings.AuthorColors)
        {
            var safeAuthor = EscapeCssSelector(kvp.Key);
            sb.AppendLine($"[data-author=\"{safeAuthor}\"] {{ border-left: 3px solid {kvp.Value}; }}");
        }
    }

    return sb.ToString();
}

6. Processing Pipeline Adjustments

When RenderTrackedChanges is enabled, several preprocessing steps need adjustment:

  1. Skip RevisionAccepter.AcceptRevisions() - Already handled

  2. Adjust SimplifyMarkupSettings:

    • Keep RemoveComments = true (or make configurable)
    • Keep RemoveProof = true
    • Ensure revision elements are NOT removed
  3. Adjust FormattingAssembler:

    • Need to ensure it doesn't strip revision metadata
    • May need to pass through revision attributes

7. Edge Cases to Handle

  1. Nested revisions: An insertion containing a deletion (rare but possible)
  2. Split revisions: A single revision spanning multiple paragraphs
  3. Revisions in tables: Rows, cells, and content all have different handling
  4. Revisions in headers/footers: Need to process all document parts
  5. Revisions in footnotes/endnotes: Similar to headers/footers
  6. Field codes with revisions: w:delInstrText vs w:instrText
  7. Math with revisions: m:r elements containing w:ins/w:del
  8. Content controls with revisions: customXmlInsRangeStart, etc.

8. Testing Strategy

Create test cases for:

  1. Simple text insertion
  2. Simple text deletion
  3. Multiple insertions by different authors
  4. Move operations
  5. Paragraph merge (deleted paragraph mark)
  6. Paragraph split (inserted paragraph mark)
  7. Table row insertion/deletion
  8. Table cell insertion/deletion
  9. Formatting changes
  10. Nested revisions
  11. Revisions in footnotes
  12. Revisions in headers/footers
  13. Complex documents with mixed revision types

9. Implementation Phases

Phase 1: Core Infrastructure ✅ COMPLETE

  • Add settings properties (RenderTrackedChanges, RevisionCssClassPrefix, etc.)
  • Modify entry point to conditionally skip revision acceptance
  • Add w:ins and w:del handlers for inline content
  • Handle w:delText
  • Generate basic CSS
  • Add unit tests (HC003-HC006)

Phase 2: Extended Content Types ✅ COMPLETE

  • Move operations (w:moveFrom/w:moveTo)
  • RenderMoveOperations setting
  • Paragraph mark revisions (w:pPr/w:rPr/w:ins and w:del)
  • Table row revisions (w:trPr/w:ins and w:del)
  • CSS for moves, paragraph marks, table rows

Phase 3: Advanced Features ✅ COMPLETE

  • Property change revisions (w:rPrChange) with DescribeFormatChange helper
  • Table cell revisions (w:cellIns, w:cellDel, w:cellMerge)
  • Author coloring (AuthorColors dictionary - CSS already implemented)
  • Footnote/endnote rendering (RenderFootnotesAndEndnotes setting)
  • Footnote/endnote CSS (section.footnotes, a.footnote-ref, etc.)
  • Add unit tests (HC007-HC008)

Phase 4: Polish

  • Header/footer rendering (RenderHeadersAndFooters setting)
  • Header/footer CSS (header.document-header, footer.document-footer)
  • Add unit tests (HC009-HC010)
  • Revisions in footnotes/endnotes (handled automatically via ConvertToHtmlTransform)
  • Revisions in headers/footers (handled automatically via ConvertToHtmlTransform)
  • Edge cases:
    • Nested revisions: handled via recursive ConvertToHtmlTransform calls
    • Split revisions: each occurrence rendered with consistent metadata
  • Comprehensive testing (HC003-HC013: insertions, deletions, CSS, move ops, author colors, all features)
  • Documentation updates

Implementation Status: ✅ COMPLETE

All phases completed. The tracked changes HTML rendering feature is fully implemented with:

  • 13 tests covering all functionality (HC001-HC013)
  • Full backward compatibility (default behavior unchanged)
  • Support for insertions, deletions, moves, paragraph marks, table rows/cells, format changes
  • Footnote/endnote rendering
  • Header/footer rendering
  • Author-specific coloring via CSS attribute selectors
  • Semantic HTML5 output (<ins>, <del>, <header>, <footer>)

API Usage Example

var settings = new WmlToHtmlConverterSettings
{
    PageTitle = "Document with Tracked Changes",
    RenderTrackedChanges = true,
    IncludeRevisionMetadata = true,
    ShowDeletedContent = true,
    RenderMoveOperations = true,
    RenderFootnotesAndEndnotes = true,
    RenderHeadersAndFooters = true,
    AuthorColors = new Dictionary<string, string>
    {
        { "John Doe", "#0066cc" },
        { "Jane Smith", "#cc6600" }
    }
};

var html = WmlToHtmlConverter.ConvertToHtml(wmlDoc, settings);

Pagination Compatibility

Tracked changes rendering is fully compatible with the client-side pagination system (RenderPagination = PaginationMode.Paginated). Both features can be enabled simultaneously.

How It Works

  1. C# Generation: When both features are enabled, the HTML output contains:

    • Pagination staging area with dimension data attributes
    • Tracked change markup (<ins>, <del>, etc.) within the content
    • CSS for both pagination and tracked changes (distinct prefixes: page- and rev-)
  2. TypeScript Pagination: The pagination engine processes tracked changes transparently:

    • getBoundingClientRect() correctly measures content including styled revisions
    • cloneNode(true) preserves all tracked change elements, classes, and data-* attributes
    • No special handling is required - tracked changes are standard DOM elements

Combined Usage Example

var settings = new WmlToHtmlConverterSettings
{
    PageTitle = "Paginated Document with Tracked Changes",
    RenderTrackedChanges = true,
    IncludeRevisionMetadata = true,
    RenderPagination = PaginationMode.Paginated,
    PaginationScale = 0.8,
    RenderFootnotesAndEndnotes = true,
    RenderHeadersAndFooters = true
};

var html = WmlToHtmlConverter.ConvertToHtml(wmlDoc, settings);
// Pass to PaginationEngine in browser for page layout

Supported Scenarios

Content TypePagination Support
Inline insertions/deletions✅ Measured and cloned correctly
Move operations✅ Preserved with data-move-id linking
Table row revisions✅ Part of block measurement
Format changes✅ Spans with tooltips preserved
Revisions in headers/footers✅ Cloned via header/footer registry
Revisions in footnotes✅ Cloned via footnote registry

Edge Cases

  • Move pairs across pages: If moved content source and destination land on different pages, the visual connection between them is lost (this matches Word's behavior when printing)
  • Page boundary clipping: The overflow: hidden on page containers may clip text decorations (underline/strikethrough) at exact boundaries - negligible in practice

See Pagination Architecture for details on the pagination system.

Conclusion

This design provides a comprehensive approach to rendering tracked changes in HTML while:

  • Maintaining backward compatibility (default behavior unchanged)
  • Supporting all major revision types
  • Providing flexible customization options
  • Generating semantic HTML (<ins>, <del>)
  • Including machine-readable metadata for tooling
  • Producing accessible and visually clear output