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