Table of Contents

Compare documents and review revisions

Compare two versions of a document into one .docx with every difference as a tracked change, then list, filter and resolve revisions from code. Resolving works the same way on documents that people edited in Word with Track Changes on.

Comparing needs the DocWright.Compare package. Revisions are part of the core DOM (DocWright.Dom.Revisions), so reviewing a document that already has tracked changes needs nothing extra.

<PackageReference Include="DocWright.Compare" Version="1.*" />

For a worked example from start to finish, see the tutorial Compare two versions of a contract.

Compare two documents

using DocWright;
using DocWright.Compare;
using DocWright.Dom;

using WordDocument original = Load("v1.docx");
using WordDocument revised = Load("v2.docx");

var options = new CompareOptions
{
    Author = "Legal",
    Timestamp = "2026-10-01T09:00:00Z",    // written verbatim; the clock is never read
};

CompareResult result = new DocumentComparer().Compare(original, revised, options);
Console.WriteLine($"{result.InsertionCount} insertions, {result.DeletionCount} deletions, "
                + $"{result.FormatChangeCount} formatting changes, {result.Moves.Count} move(s)");
Console.WriteLine($"alignment fell back: {result.AlignmentFellBack}");

using (FileStream docx = File.Create("compared.docx"))
{
    converter.Save(result.Document, docx);
}

The result contains the revised document's styles, numbering and theme, and a body that holds both versions as tracked changes. Open it in Word and review it like any other comparison. Download: compared.docx.

Output (all output from this page's samples):

8 insertions, 6 deletions, 1 formatting changes, 1 move(s)
alignment fell back: False
Word      Report +8 -6 format 0 moves 1
Word      Match  +7 -5 format 0 moves 1
Word      None   +8 -6 format 0 moves 0
Character Report +12 -10 format 0 moves 1
Character Match  +11 -9 format 0 moves 1
Character None   +12 -10 format 0 moves 0
21 revisions:
  #1   Deletion             Legal 2026-10-01  "1,400"
  #2   Insertion            Legal 2026-10-01  "1,450"
  #3   Deletion             Legal 2026-10-01  "30"
  #4   Insertion            Legal 2026-10-01  "20"
  #5   Insertion            Legal 2026-10-01  "working"
  #6   Deletion             Legal 2026-10-01  "Each party will keep the other party's confidential information secret and use …"
  #7   ParagraphMarkDeletion Legal 2026-10-01  ""
  #8   Deletion             Legal 2026-10-01  "5. Governing law"
  Insertion: 8
  Deletion: 6
  ParagraphMarkInsertion: 4
  ParagraphMarkDeletion: 2
  RunFormatting: 1
rejected 1 formatting change(s), accepted 8 insertion(s)
rejecting "1,400": True
11 revision(s) still open
fee paragraph: 5 revision(s) of 21 in the document
after accepting it: 16 left
accept all == revised:  True
two comparisons byte-identical: True
Note

A PDF or image of the compared document shows the final text, with every insertion applied and every deletion removed. DocWright doesn't draw revision marks yet. ConvertOptions.RenderRevisions is accepted but only raises a warning, which becomes an exception under StrictMode. To see the markup, open the .docx in Word.

Granularity and moves

using DocWright.Compare;

// The same two documents at each granularity and move setting.
foreach (CompareGranularity granularity in new[] { CompareGranularity.Word, CompareGranularity.Character })
{
    foreach (MoveDetection moves in new[] { MoveDetection.Report, MoveDetection.Match, MoveDetection.None })
    {
        CompareResult r = new DocumentComparer().Compare(original, revised, new CompareOptions
        {
            Author = "Legal",
            Granularity = granularity,
            MoveDetection = moves,
            IgnoreFormatting = true,
        });
        Console.WriteLine($"{granularity,-9} {moves,-6} +{r.InsertionCount} -{r.DeletionCount} "
                        + $"format {r.FormatChangeCount} moves {r.Moves.Count}");
    }
}

The output rows beginning Word and Character show what these options change:

  • Granularity = Word (the default) marks whole words, so "1,400" becomes "1,450" as one deletion and one insertion. Character marks only the characters that differ. That gives more but smaller revisions, and the cost grows quadratically with paragraph length.
  • MoveDetection = Report (the default) marks a moved paragraph as a deletion plus an insertion and lists it in result.Moves, each with OriginalIndex, RevisedIndex and a short Preview. Match writes the paragraph once in its new place with no markup, which is quieter but can't restore the original order when rejected. None doesn't look for moves at all.
  • IgnoreFormatting skips formatting-only changes. Use it when you care only about wording.

All options

Option Default Use it to…
Author "DocWright" Set the author recorded on every revision. It can't be null, empty or whitespace.
Timestamp null Set the date written on every revision, as a string used exactly as given. With null, no date is written. The clock is never read.
StartingRevisionId 1 Choose the first revision id. Raise it when the result will be combined with a document that already has revisions.
Granularity Word Mark whole words or single characters.
MoveDetection Report Choose Report, Match or None, as described above.
MinimumMoveLength 24 Set the shortest paragraph, in characters, that can count as a move. Short repeated lines are not reported as moves.
MinimumParagraphSimilarity 0.35 Set how alike two paragraphs must be to be compared word by word instead of treated as a replacement.
IgnoreFormatting false Leave out formatting-only changes.
IgnoreWhitespace, IgnoreCase false Ignore spacing or case when matching text. The result still carries the revised document's text.
MaxAlignmentCells 1,000,000 Cap the matching work. Above it, a cheaper strategy is used and result.AlignmentFellBack is true.
MaxBlocks, MaxTokensPerParagraph 200,000, 100,000 Set hard limits. A larger document throws ResourceLimitExceededException instead of running without bound.

List revisions

GetRevisions() returns every revision under a node, in document order:

using DocWright.Dom.Revisions;

IReadOnlyList<Revision> revisions = result.Document.GetRevisions();
Console.WriteLine($"{revisions.Count} revisions:");
foreach (Revision revision in revisions.Take(8))
{
    Console.WriteLine($"  #{revision.Id,-3} {revision.Type,-20} {revision.Author} "
                    + $"{revision.Timestamp:yyyy-MM-dd}  \"{revision.Preview}\"");
}

// How many of each type.
foreach (var group in revisions.GroupBy(r => r.Type).OrderBy(g => g.Key))
{
    Console.WriteLine($"  {group.Key}: {group.Count()}");
}

Each Revision has a Type, Author, the raw Date string, a parsed Timestamp, an Id, a plain-text Preview, and the Target node it marks. The types are:

RevisionType Meaning
Insertion, Deletion Text that was added or removed.
ParagraphMarkInsertion, ParagraphMarkDeletion A paragraph break that was added or removed, which splits or joins paragraphs.
RunFormatting, ParagraphFormatting A formatting change. The old formatting is kept so the change can be rejected.
TableRowInsertion, TableRowDeletion A whole table row that was added or removed.

Accept and reject

using DocWright.Dom.Revisions;

// Reject every formatting change, whoever made it.
int rejected = result.Document.RejectRevisions(new RevisionFilter
{
    Types = [RevisionType.RunFormatting, RevisionType.ParagraphFormatting],
});

// Accept what one author did on or after a date.
int accepted = result.Document.AcceptRevisions(new RevisionFilter
{
    Author = "Legal",
    NotBefore = new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero),
    Types = [RevisionType.Insertion],
});
Console.WriteLine($"rejected {rejected} formatting change(s), accepted {accepted} insertion(s)");

// Decide one at a time: reject the first deletion that is left.
Revision? firstDeletion = result.Document.GetRevisions()
    .FirstOrDefault(r => r.Type == RevisionType.Deletion);
if (firstDeletion is not null)
{
    Console.WriteLine($"rejecting \"{firstDeletion.Preview}\": {firstDeletion.Reject()}");
}

Console.WriteLine($"{result.Document.GetRevisions().Count} revision(s) still open");
  • RevisionFilter matches on Author, Types, Id, and a NotBefore/NotAfter window. Every property you set must match. filter.Matches(revision) applies the same test to one revision.
  • AcceptRevisions and RejectRevisions return the number of revisions they resolved. AcceptAllRevisions and RejectAllRevisions resolve everything under the node.
  • A handle is a snapshot. If resolving one revision removes the paragraph another handle points at, that handle reports IsStale, and its Accept() and Reject() return false without changing anything else. When in doubt, call GetRevisions() again after a batch of changes.

Limit the scope to part of a document

Every revision method is an extension on DomNode, so the node you call it on is the scope. That can be a section, a paragraph, a table, a cell, a header or footer, or a text box:

using DocWright.Dom;
using DocWright.Dom.Revisions;

// Any node is a scope: here, only the paragraph that mentions the fee.
CompareResult fresh = new DocumentComparer().Compare(original, revised, options);
Paragraph fees = fresh.Document.Sections[0].Blocks.OfType<Paragraph>()
    .First(p => p.GetText().Contains("per day"));
Console.WriteLine($"fee paragraph: {fees.GetRevisions().Count} revision(s) "
                + $"of {fresh.Document.GetRevisions().Count} in the document");
fees.AcceptAllRevisions();
Console.WriteLine($"after accepting it: {fresh.Document.GetRevisions().Count} left");

This is how you accept one clause and leave the rest for someone else to review.

What you can rely on

using DocWright.Compare;
using DocWright.Dom;
using DocWright.Dom.Revisions;

// Accept everything and you have the revised text.
static string Text(WordDocument d) => string.Join("\n", d.Sections.SelectMany(s => s.Blocks).Select(b => b.GetText()));

CompareResult toAccept = new DocumentComparer().Compare(original, revised, options);
toAccept.Document.AcceptAllRevisions();
Console.WriteLine($"accept all == revised:  {Text(toAccept.Document) == Text(revised)}");

// No clock and no random ids: the same inputs and options give the same bytes.
byte[] Save(CompareResult r)
{
    using var stream = new MemoryStream();
    converter.Save(r.Document, stream);
    return stream.ToArray();
}

bool identical = Save(new DocumentComparer().Compare(original, revised, options))
    .AsSpan().SequenceEqual(Save(new DocumentComparer().Compare(original, revised, options)));
Console.WriteLine($"two comparisons byte-identical: {identical}");
  • Accepting every revision gives you the revised document's text. The comparer's other choices only decide how tidily the differences are marked.
  • The output is deterministic. Comparing the same inputs with the same options saves byte-identical files, because no clock or random id is involved.
Warning

Known issue: rejecting every revision should give back the original document. At present, when a comparison both removes and adds whole paragraphs, RejectAllRevisions can put some of the restored paragraphs back in the wrong order, whatever MoveDetection is set to. The text of each paragraph is correct. Rejecting changes within paragraphs, and accepting in general, are not affected.

Next steps