using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace MiniMaxAIDocx.Core.Samples;
///
/// Reference implementations for revision tracking (Track Changes).
///
/// ╔══════════════════════════════════════════════════════════════════╗
/// ║ CRITICAL: w:del uses w:delText, NEVER w:t ║
/// ║ w:ins uses w:t, NEVER w:delText ║
/// ║ Getting this wrong silently corrupts the document. ║
/// ║ Word will open without error but display garbled text or ║
/// ║ lose content when accepting/rejecting changes. ║
/// ╚══════════════════════════════════════════════════════════════════╝
///
/// KEY CONCEPTS:
/// - Every revision element (ins, del, rPrChange, pPrChange) needs:
/// w:id — unique revision ID (string, must be unique across all revisions)
/// w:author — who made the change
/// w:date — ISO 8601 timestamp
/// - InsertedRun (w:ins) wraps normal Run elements with w:t text
/// - DeletedRun (w:del) wraps Run elements that use DeletedText (w:delText) instead of Text (w:t)
/// - MoveFrom/MoveTo track text that was moved (not just deleted+inserted)
///
public static class TrackChangesSamples
{
///
/// Thread-safe counter for generating unique revision IDs.
/// In production, scan the document for the max existing ID first.
///
private static int s_revisionCounter;
// ──────────────────────────────────────────────
// 1. EnableTrackChanges
// ──────────────────────────────────────────────
///
/// Enables revision tracking in the document settings.
/// This makes Word record all subsequent edits as tracked changes.
///
/// Maps to: <w:trackChanges/> in settings.xml
///
/// Note: This only controls whether NEW edits are tracked.
/// Existing revision marks are always preserved regardless of this setting.
///
public static void EnableTrackChanges(DocumentSettingsPart settingsPart)
{
settingsPart.Settings ??= new Settings();
var existing = settingsPart.Settings.GetFirstChild();
if (existing == null)
{
settingsPart.Settings.Append(new TrackRevisions());
}
settingsPart.Settings.Save();
}
// ──────────────────────────────────────────────
// 2. InsertTrackedInsertion — w:ins with w:t
// ──────────────────────────────────────────────
///
/// Inserts text as a tracked insertion (w:ins).
///
/// ╔══════════════════════════════════════════════════════╗
/// ║ w:ins uses w:t (Text), NOT w:delText. ║
/// ║ The text appears with green underline in Word. ║
/// ╚══════════════════════════════════════════════════════╝
///
/// XML structure:
/// <w:ins w:id="1" w:author="John" w:date="2026-03-22T00:00:00Z">
/// <w:r>
/// <w:t>inserted text</w:t> <!-- w:t, NOT w:delText -->
/// </w:r>
/// </w:ins>
///
public static InsertedRun InsertTrackedInsertion(Paragraph para, string text, string author)
{
var ins = new InsertedRun
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// CORRECT: w:ins contains w:r with w:t (normal Text element)
ins.Append(new Run(
new Text(text) { Space = SpaceProcessingModeValues.Preserve }));
para.Append(ins);
return ins;
}
// ──────────────────────────────────────────────
// 3. InsertTrackedDeletion — w:del with w:delText
// ──────────────────────────────────────────────
///
/// Inserts text as a tracked deletion (w:del).
///
/// ╔══════════════════════════════════════════════════════╗
/// ║ w:del uses w:delText (DeletedText), NOT w:t. ║
/// ║ Using w:t inside w:del SILENTLY CORRUPTS the file. ║
/// ║ The text appears with red strikethrough in Word. ║
/// ╚══════════════════════════════════════════════════════╝
///
/// XML structure:
/// <w:del w:id="2" w:author="John" w:date="2026-03-22T00:00:00Z">
/// <w:r>
/// <w:delText xml:space="preserve">deleted text</w:delText> <!-- w:delText, NOT w:t -->
/// </w:r>
/// </w:del>
///
public static DeletedRun InsertTrackedDeletion(Paragraph para, string deletedText, string author)
{
var del = new DeletedRun
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// CORRECT: w:del contains w:r with w:delText (DeletedText element)
// WRONG would be: new Text(deletedText) — this creates w:t which corrupts the document
del.Append(new Run(
new DeletedText(deletedText) { Space = SpaceProcessingModeValues.Preserve }));
para.Append(del);
return del;
}
// ──────────────────────────────────────────────
// 4. InsertFormattingChange — RunPropertiesChange
// ──────────────────────────────────────────────
///
/// Records a formatting change on a run (e.g., text was made bold).
///
/// RunPropertiesChange (w:rPrChange) stores the PREVIOUS formatting.
/// The current RunProperties on the run reflects the NEW formatting.
///
/// Example: text changed from normal to bold:
/// <w:rPr>
/// <w:b/> <!-- current: bold -->
/// <w:rPrChange w:id="3" w:author="John" w:date="...">
/// <w:rPr/> <!-- previous: no bold -->
/// </w:rPrChange>
/// </w:rPr>
///
public static void InsertFormattingChange(Run run, string author)
{
// Ensure RunProperties exists
run.RunProperties ??= new RunProperties();
// Store the previous (empty/normal) formatting as the "before" state
var rPrChange = new RunPropertiesChange
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// The child RunProperties inside rPrChange is the OLD formatting (before the change).
// An empty RunProperties means "was default/normal formatting."
rPrChange.Append(new PreviousRunProperties());
run.RunProperties.Append(rPrChange);
}
// ──────────────────────────────────────────────
// 5. InsertParagraphFormatChange — ParagraphPropertiesChange
// ──────────────────────────────────────────────
///
/// Records a paragraph formatting change (e.g., alignment changed).
///
/// ParagraphPropertiesChange (w:pPrChange) stores the PREVIOUS paragraph properties.
/// The current ParagraphProperties reflects the NEW formatting.
///
/// Example: paragraph changed from left-aligned to centered:
/// <w:pPr>
/// <w:jc w:val="center"/> <!-- current: centered -->
/// <w:pPrChange w:id="4" w:author="John" w:date="...">
/// <w:pPr>
/// <w:jc w:val="left"/> <!-- previous: left -->
/// </w:pPr>
/// </w:pPrChange>
/// </w:pPr>
///
public static void InsertParagraphFormatChange(Paragraph para, string author)
{
para.ParagraphProperties ??= new ParagraphProperties();
var pPrChange = new ParagraphPropertiesChange
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// Store previous paragraph properties (before the change)
// Example: was left-aligned before changing to whatever the current alignment is
var previousPPr = new ParagraphPropertiesExtended();
previousPPr.Append(new Justification { Val = JustificationValues.Left });
pPrChange.Append(previousPPr);
para.ParagraphProperties.Append(pPrChange);
}
// ──────────────────────────────────────────────
// 6. InsertTableRowInsertion — table revision marks
// ──────────────────────────────────────────────
///
/// Marks a table row as a tracked insertion.
///
/// Table-level track changes use TableRowProperties with InsertedMathControl
/// mapped from w:trPr/w:ins — indicating the entire row was inserted.
///
/// Structure:
/// <w:tr>
/// <w:trPr>
/// <w:ins w:id="5" w:author="John" w:date="..."/>
/// </w:trPr>
/// <w:tc>...</w:tc>
/// </w:tr>
///
public static void InsertTableRowInsertion(TableRow row, string author)
{
row.TableRowProperties ??= new TableRowProperties();
var inserted = new Inserted
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
row.TableRowProperties.Append(inserted);
}
// ──────────────────────────────────────────────
// 7. AcceptAllRevisions — accept all tracked changes
// ──────────────────────────────────────────────
///
/// Programmatically accepts all tracked changes in the document body.
///
/// For insertions (w:ins): unwrap the content (keep the runs, remove the w:ins wrapper)
/// For deletions (w:del): remove the entire element (the deleted text disappears)
/// For formatting changes: remove the rPrChange/pPrChange (keep new formatting)
/// For table row insertions: remove the w:ins from trPr
///
/// ╔══════════════════════════════════════════════════════════════╗
/// ║ Process deletions before insertions to avoid invalidating ║
/// ║ element references. Always call .ToList() before ║
/// ║ iterating to avoid modifying the collection during ║
/// ║ enumeration. ║
/// ╚══════════════════════════════════════════════════════════════╝
///
public static void AcceptAllRevisions(Body body)
{
// 1. Accept deletions — remove the w:del and all its content
foreach (var del in body.Descendants().ToList())
{
del.Remove();
}
// 2. Accept insertions — unwrap w:ins, keeping child runs in place
foreach (var ins in body.Descendants().ToList())
{
var parent = ins.Parent;
if (parent == null) continue;
// Move all child elements before the ins element, then remove ins
var children = ins.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
ins.InsertBeforeSelf(child);
}
ins.Remove();
}
// 3. Accept formatting changes — remove rPrChange (keep new formatting)
foreach (var rPrChange in body.Descendants().ToList())
{
rPrChange.Remove();
}
// 4. Accept paragraph formatting changes
foreach (var pPrChange in body.Descendants().ToList())
{
pPrChange.Remove();
}
// 5. Accept table row insertions — remove w:ins from trPr
foreach (var inserted in body.Descendants()
.SelectMany(trPr => trPr.Elements()).ToList())
{
inserted.Remove();
}
// 6. Accept MoveFrom/MoveTo — keep MoveTo content, remove MoveFrom
foreach (var moveFrom in body.Descendants().ToList())
{
moveFrom.Remove();
}
foreach (var moveTo in body.Descendants().ToList())
{
var parent = moveTo.Parent;
if (parent == null) continue;
var children = moveTo.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
moveTo.InsertBeforeSelf(child);
}
moveTo.Remove();
}
// 7. Remove move range markers
foreach (var marker in body.Descendants().ToList()) marker.Remove();
foreach (var marker in body.Descendants().ToList()) marker.Remove();
foreach (var marker in body.Descendants().ToList()) marker.Remove();
foreach (var marker in body.Descendants().ToList()) marker.Remove();
}
// ──────────────────────────────────────────────
// 8. RejectAllRevisions — reject all tracked changes
// ──────────────────────────────────────────────
///
/// Programmatically rejects all tracked changes in the document body.
///
/// For insertions (w:ins): remove the entire element (the inserted text disappears)
/// For deletions (w:del): unwrap the content and convert w:delText back to w:t
/// (the "deleted" text is restored)
/// For formatting changes: restore old formatting from rPrChange/pPrChange
///
/// ╔══════════════════════════════════════════════════════════════╗
/// ║ When rejecting deletions, you MUST convert w:delText back ║
/// ║ to w:t. Leaving w:delText in a non-deleted run causes ║
/// ║ the text to be invisible in Word. ║
/// ╚══════════════════════════════════════════════════════════════╝
///
public static void RejectAllRevisions(Body body)
{
// 1. Reject insertions — remove the entire w:ins and its content
foreach (var ins in body.Descendants().ToList())
{
ins.Remove();
}
// 2. Reject deletions — restore deleted text by unwrapping w:del
// and converting w:delText back to w:t
foreach (var del in body.Descendants().ToList())
{
var parent = del.Parent;
if (parent == null) continue;
// Convert DeletedText -> Text in each run inside the deletion
foreach (var run in del.Elements().ToList())
{
foreach (var delText in run.Elements().ToList())
{
// IMPORTANT: convert w:delText back to w:t
var text = new Text(delText.Text ?? "") { Space = SpaceProcessingModeValues.Preserve };
delText.InsertAfterSelf(text);
delText.Remove();
}
}
// Unwrap — move children before the del element
var children = del.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
del.InsertBeforeSelf(child);
}
del.Remove();
}
// 3. Reject formatting changes — restore old RunProperties
foreach (var rPrChange in body.Descendants().ToList())
{
var runProperties = rPrChange.Parent as RunProperties;
if (runProperties == null) continue;
// Get the previous (old) formatting
var previousRPr = rPrChange.GetFirstChild();
if (previousRPr != null)
{
// Remove current formatting (except the rPrChange itself)
var currentProps = runProperties.ChildElements
.Where(c => c is not RunPropertiesChange).ToList();
foreach (var prop in currentProps)
{
prop.Remove();
}
// Restore old formatting from PreviousRunProperties
foreach (var oldProp in previousRPr.ChildElements.ToList())
{
oldProp.Remove();
runProperties.Append(oldProp);
}
}
rPrChange.Remove();
}
// 4. Reject paragraph formatting changes — restore old ParagraphProperties
foreach (var pPrChange in body.Descendants().ToList())
{
var paragraphProperties = pPrChange.Parent as ParagraphProperties;
if (paragraphProperties == null) continue;
var previousPPr = pPrChange.GetFirstChild();
if (previousPPr != null)
{
var currentProps = paragraphProperties.ChildElements
.Where(c => c is not ParagraphPropertiesChange).ToList();
foreach (var prop in currentProps)
{
prop.Remove();
}
foreach (var oldProp in previousPPr.ChildElements.ToList())
{
oldProp.Remove();
paragraphProperties.Append(oldProp);
}
}
pPrChange.Remove();
}
// 5. Reject table row insertions — remove the entire row
foreach (var row in body.Descendants().ToList())
{
var trPr = row.TableRowProperties;
if (trPr?.GetFirstChild() != null)
{
row.Remove();
}
}
// 6. Reject MoveFrom/MoveTo — keep MoveFrom content (original position), remove MoveTo
foreach (var moveTo in body.Descendants().ToList())
{
moveTo.Remove();
}
foreach (var moveFrom in body.Descendants().ToList())
{
var parent = moveFrom.Parent;
if (parent == null) continue;
// Convert any DeletedText back to Text in MoveFrom runs
foreach (var run in moveFrom.Elements().ToList())
{
foreach (var delText in run.Elements().ToList())
{
var text = new Text(delText.Text ?? "") { Space = SpaceProcessingModeValues.Preserve };
delText.InsertAfterSelf(text);
delText.Remove();
}
}
var children = moveFrom.ChildElements.ToList();
foreach (var child in children)
{
child.Remove();
moveFrom.InsertBeforeSelf(child);
}
moveFrom.Remove();
}
// 7. Remove move range markers
foreach (var marker in body.Descendants().ToList()) marker.Remove();
foreach (var marker in body.Descendants().ToList()) marker.Remove();
foreach (var marker in body.Descendants().ToList()) marker.Remove();
foreach (var marker in body.Descendants().ToList()) marker.Remove();
}
// ──────────────────────────────────────────────
// 9. InsertMoveFromTo — MoveFrom + MoveTo blocks
// ──────────────────────────────────────────────
///
/// Creates a tracked move operation (text moved from one location to another).
///
/// A move consists of:
/// - MoveFromRangeStart/End markers around the original location
/// - MoveFrom (w:moveFrom) containing the original text with w:delText
/// - MoveToRangeStart/End markers around the new location
/// - MoveTo (w:moveTo) containing the moved text with w:t
/// - Both share the same name attribute to link them
///
/// ╔══════════════════════════════════════════════════════════════╗
/// ║ MoveFrom uses w:delText (like w:del — text is "leaving") ║
/// ║ MoveTo uses w:t (like w:ins — text is "arriving") ║
/// ╚══════════════════════════════════════════════════════════════╝
///
public static void InsertMoveFromTo(Body body, string movedText, string author)
{
string moveId = GenerateRevisionId();
string moveId2 = GenerateRevisionId();
string moveName = "move" + moveId;
// ── MoveFrom paragraph (original location — text shown with strikethrough) ──
var moveFromPara = new Paragraph();
moveFromPara.Append(new MoveFromRangeStart
{
Id = moveId,
Author = author,
Date = DateTime.UtcNow,
Name = moveName
});
var moveFrom = new MoveFromRun
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// MoveFrom uses DeletedText (w:delText), NOT Text (w:t)
// The text is visually struck through in Word
moveFrom.Append(new Run(
new DeletedText(movedText) { Space = SpaceProcessingModeValues.Preserve }));
moveFromPara.Append(moveFrom);
moveFromPara.Append(new MoveFromRangeEnd { Id = moveId });
body.Append(moveFromPara);
// ── MoveTo paragraph (destination — text shown with double underline) ──
var moveToPara = new Paragraph();
moveToPara.Append(new MoveToRangeStart
{
Id = moveId2,
Author = author,
Date = DateTime.UtcNow,
Name = moveName
});
var moveTo = new MoveToRun
{
Id = GenerateRevisionId(),
Author = author,
Date = DateTime.UtcNow
};
// MoveTo uses Text (w:t), NOT DeletedText (w:delText)
// The text is visually double-underlined in green in Word
moveTo.Append(new Run(
new Text(movedText) { Space = SpaceProcessingModeValues.Preserve }));
moveToPara.Append(moveTo);
moveToPara.Append(new MoveToRangeEnd { Id = moveId2 });
body.Append(moveToPara);
}
// ──────────────────────────────────────────────
// 10. GenerateRevisionId — unique ID pattern
// ──────────────────────────────────────────────
///
/// Generates a unique revision ID string.
///
/// Revision IDs (w:id) must be unique across ALL revision elements in the document:
/// ins, del, rPrChange, pPrChange, moveFrom, moveTo, table row ins/del, etc.
///
/// Word uses simple incrementing integers starting from 0.
/// When programmatically adding revisions to an existing document,
/// first scan for the maximum existing ID and start from there.
///
/// For new documents, a simple counter suffices.
/// For existing documents, use:
/// int maxId = body.Descendants()
/// .SelectMany(e => e.GetAttributes())
/// .Where(a => a.LocalName == "id")
/// .Select(a => int.TryParse(a.Value, out int v) ? v : 0)
/// .DefaultIfEmpty(0)
/// .Max();
///
public static string GenerateRevisionId()
{
return Interlocked.Increment(ref s_revisionCounter).ToString();
}
}