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.Charactermarks 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 inresult.Moves, each withOriginalIndex,RevisedIndexand a shortPreview.Matchwrites the paragraph once in its new place with no markup, which is quieter but can't restore the original order when rejected.Nonedoesn't look for moves at all.IgnoreFormattingskips 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");
RevisionFiltermatches onAuthor,Types,Id, and aNotBefore/NotAfterwindow. Every property you set must match.filter.Matches(revision)applies the same test to one revision.AcceptRevisionsandRejectRevisionsreturn the number of revisions they resolved.AcceptAllRevisionsandRejectAllRevisionsresolve 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 itsAccept()andReject()returnfalsewithout changing anything else. When in doubt, callGetRevisions()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
- Compare two versions of a contract, a tutorial from start to finish with screenshots.
- Read text and walk the document for
GetTextandRevisionView.Original.