Compare Word Documents and Extract Differences with JavaScript in React

Contract revisions, proposal reviews and policy updates all end with the same question: what exactly changed between this version and the last one. Comparing page by page is slow and easy to miss; recording the comparison as tracked changes lets each item be confirmed in Word, and the differences can then be organized into a list for the reviewer.

This article uses Spire.Doc for JavaScript to do this work in the browser. The sample uses two versions of the same "Starlan Membership Data Platform Construction Project Cooperation Proposal": Original.docx is the October 15 draft and Revised.docx is the October 28 review draft. The two versions contain more than ten changes, including the preparation date, the contract amount, section headings and the header. The whole process runs on the client side with WebAssembly, requires no Office installation and depends on no backend, so the documents stay on the user's device.

This article covers three features:

The examples read Original.docx and Revised.docx from public/data; put both files into the public/data directory of the project before running them.

For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume that Spire.Doc is installed and the WebAssembly module is initialized.


Compare Two Word Documents and Generate a Tracked Version

Comparing two Word documents and generating a tracked version is done with a single call to Document.Compare(). The method works with an "original + revised" model: the document that calls the method is the original, the document passed as the argument is the revised version, and the comparison result is written back into the calling document as tracked changes.

Role How it is provided Description
Original The document that calls Compare() Rewritten as the version with tracked changes after the comparison
Revised The document passed as an argument Only its content is read; it is not modified
Author The string passed as the second argument Written into every revision as the author name

The comparison modifies the original object itself. When the original version has to be preserved, reload the file or save a copy instead of saving the original object after the comparison.

The specific steps are as follows:

  1. Get the Spire.Doc WASM module and confirm that it is ready.
  2. Load the font and Original.docx, Revised.docx into the VFS.
  3. Create two Document instances and load the two documents.
  4. Call Compare() on the original and pass in the revised document and the author name.
  5. Save the result document, read it from the VFS and trigger a browser download.

The code for comparing two Word documents is as follows:

function App() {
  const compareDocuments = async () => {
    // Get the Spire.Doc WASM module
    const docModule = window.wasmModule?.spiredoc;

    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

    // Load the two sample documents
    await window.spire.FetchFileToVFS('Original.docx', '', `${process.env.PUBLIC_URL}/data/`);
    await window.spire.FetchFileToVFS('Revised.docx', '', `${process.env.PUBLIC_URL}/data/`);

    // Load the original and the revised document separately
    const original = new docModule.Document();
    original.LoadFromFile('Original.docx');

    const revised = new docModule.Document();
    revised.LoadFromFile('Revised.docx');

    // Call Compare on the original; the tracked changes are written into it
    original.Compare(revised, 'Contract Review');

    // Save the result document with tracked changes
    const outputFileName = 'CompareResult.docx';
    original.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });

    // Release the two document instances
    revised.Dispose();
    original.Dispose();

    // Read the result from the VFS and trigger the download
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], {
      type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Compare Two Word Documents</h1>
      <button onClick={compareDocuments}>Start Comparison</button>
    </div>
  );
}

export default App;

When the result document is opened in Word, it appears in the revision view: inserted content is underlined, deleted content is struck through, and the body structure and page margins are preserved:

Comparison result: inserted content is underlined, deleted content is struck through, and changes in the header and the document info table are recorded as well

In the Preparation row, the old day number and the new day number appear side by side with a strikethrough and an underline, corresponding to a revision that replaces the old preparation date with the new one. The whole Phase 4 bullet is new content and is therefore underlined throughout. The paragraph "The quotation does not include the procurement cost of servers, storage and other hardware; the hardware shall be purchased by Party A." has been deleted and is shown with a strikethrough. The header label is likewise recorded as a revision.

Use HasChanges to Check Whether the Two Documents Differ

After the comparison, Document.HasChanges reports whether the document contains any revisions, so the check can be made before the result is saved. When the two documents are identical in content and formatting, the property is false and all four revision collections are empty:

original.Compare(revised, 'Contract Review');

// No need to save the result document when there is no difference
if (!original.HasChanges) {
  alert('The two documents are identical');
  return;
}

Control the Scope of the Comparison with Options

The scope of the comparison is determined by the four members of CompareOptions. By default, differences in formatting, tables and headers and footers are all recorded, while real-world scenarios often care about only part of them: a policy revision cares only about the wording of the clauses, a table version check looks only at the cell contents, and a formatting adjustment can be ignored altogether.

Member Default value Effect
IgnoreFormatting false Ignore differences in character and paragraph formatting
IgnoreTable false Ignore tables; changes inside tables do not take part in the comparison
IgnoreHeadersAndFooters false Ignore headers and footers
TextCompareLevel TextDiffMode.Word Text comparison granularity; can be changed to TextDiffMode.Char

The options have to be passed in object form, that is, Compare({ document, author, options }). Calling the method with positional arguments in the order "document, author, options" reports an ambiguous call; passing an object instead resolves it.

The specific steps are as follows:

  1. Load the font and the two documents, create the original and revised instances and load them separately.
  2. Create a CompareOptions instance and enable the ignore switches as required.
  3. Pass document, author and options as an object and call Compare().
  4. Save the result document, read it from the VFS and trigger a browser download.

The code for comparing with options is as follows:

function App() {
  const compareWithOptions = async () => {
    // Get the Spire.Doc WASM module
    const docModule = window.wasmModule?.spiredoc;

    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

    await window.spire.FetchFileToVFS('Original.docx', '', `${process.env.PUBLIC_URL}/data/`);
    await window.spire.FetchFileToVFS('Revised.docx', '', `${process.env.PUBLIC_URL}/data/`);

    const original = new docModule.Document();
    original.LoadFromFile('Original.docx');

    const revised = new docModule.Document();
    revised.LoadFromFile('Revised.docx');

    // Set the comparison scope item by item
    const options = new docModule.CompareOptions();
    // Only text changes matter; font and paragraph adjustments are not recorded
    options.IgnoreFormatting = true;
    // Table contents are checked separately and are excluded from this comparison
    options.IgnoreTable = true;
    // Headers and footers are maintained by the template
    options.IgnoreHeadersAndFooters = true;

    // Pass the options as an object
    original.Compare({ document: revised, author: 'Contract Review', options });

    const outputFileName = 'CompareResultOptions.docx';
    original.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });

    revised.Dispose();
    original.Dispose();

    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], {
      type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Compare Two Word Documents with Options</h1>
      <button onClick={compareWithOptions}>Start Comparison</button>
    </div>
  );
}

export default App;

For the same documents, the default comparison and the comparison with the ignore options enabled look as follows:

Default comparison versus the comparison with the ignore options: the change in the header and the rewrite of the table cell no longer appear as revisions

On the left is the default comparison: the header shows the change between "Internal Use" and "Review Draft", and the preparation date and the notes of the "Tag Calculation" row in the document info table are marked as revisions. On the right, with the three ignore options enabled, the header returns to the original text and the changes in the table no longer generate revision marks; the table keeps the original wording.

Compare Text Differences by Character

By default the comparison works word by word, so a replacement is recorded as whole words. In the sample, the confidentiality period is changed from "three" to "five", which is recorded as "Delete three" and "Insert five". With character-level comparison, only the characters that are the same before and after are kept as common content; the same change becomes "Delete thre" and "Insert fiv", and when several passages are rewritten at once, the number of scattered entries increases noticeably.

const options = new docModule.CompareOptions();

// Compare text by character, so that differences are located down to a single character
options.TextCompareLevel = docModule.TextDiffMode.Char;

original.Compare({ document: revised, author: 'Contract Review', options });

After Formatting Is Ignored, the Result Uses the Original Formatting

With IgnoreFormatting enabled, the result document no longer contains formatting revisions, and the affected paragraphs keep the font of the original; font changes made in the revised document are not written into the result. In the sample, the "7. Other Terms" heading is changed from Arial to Georgia in the revised version; with this option enabled, the result document still shows the original Arial.

const options = new docModule.CompareOptions();

// Compare text only; formatting changes do not generate revision marks
options.IgnoreFormatting = true;

original.Compare({ document: revised, author: 'Contract Review', options });

When the layout of the revised version has to be reflected in the result, do not enable this option; let the formatting differences be kept as revisions instead.


Extract and Export a List of Differences

Extracting and exporting a list of differences is based on DifferRevisions. Tracked changes are suitable for confirming items one by one in Word, while batch scenarios need a searchable list: DifferRevisions groups the revisions produced by the comparison by type, so the inserted, deleted, moved-to and moved-from content can be taken out separately and assembled into text for export.

Member Effect
new DifferRevisions(doc) Creates a revision collection based on the compared document
InsertRevisions The collection of inserted content
DeleteRevisions The collection of deleted content
MoveToRevisions The collection of content moved to another position
MoveFromRevisions The collection of content moved from another position

All four collections provide a Count property and a get(i) method, which take the elements out one by one by index.

The specific steps are as follows:

  1. Load the font and the two documents and complete the comparison.
  2. Create DifferRevisions based on the compared document.
  3. Iterate through the four revision collections in turn, take out the text objects and read their text.
  4. Assemble the result into tab-separated text, write it into the VFS, and save the comparison result document as well.
  5. Download the result document and the list file separately.

The code for extracting the list of differences is as follows:

function App() {
  const extractDifferences = async () => {
    // Get the Spire.Doc WASM module
    const docModule = window.wasmModule?.spiredoc;

    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

    await window.spire.FetchFileToVFS('Original.docx', '', `${process.env.PUBLIC_URL}/data/`);
    await window.spire.FetchFileToVFS('Revised.docx', '', `${process.env.PUBLIC_URL}/data/`);

    const original = new docModule.Document();
    original.LoadFromFile('Original.docx');

    const revised = new docModule.Document();
    revised.LoadFromFile('Revised.docx');

    original.Compare(revised, 'Contract Review');

    // Create the revision collections based on the compared document
    const revisions = new docModule.DifferRevisions(original);
    const rows = [];

    const collect = (collection, label) => {
      for (let i = 0; i < collection.Count; i++) {
        const item = collection.get(i);
        // The revision collections contain both paragraphs and text objects; take only text objects here
        if (item.DocumentObjectType !== docModule.DocumentObjectType.TextRange) continue;
        const text = docModule.TextRange.Convert(item).Text.trim();
        if (text) rows.push(`${label}\t${text}`);
      }
    };

    collect(revisions.InsertRevisions, 'Insert');
    collect(revisions.DeleteRevisions, 'Delete');
    collect(revisions.MoveToRevisions, 'Move To');
    collect(revisions.MoveFromRevisions, 'Move From');

    // The first column is the revision type and the second column is the revision content
    const report = `Type\tContent\r\n${rows.join('\r\n')}\r\n`;
    window.dotnetRuntime.Module.FS.writeFile('CompareReport.txt', report);

    // The comparison result and the list are generated from the same comparison
    original.SaveToFile({ fileName: 'CompareResult.docx', fileFormat: docModule.FileFormat.Docx2013 });

    revisions.Dispose();
    revised.Dispose();
    original.Dispose();

    // Download the result document and the list file in turn
    const outputs = [
      ['CompareResult.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
      ['CompareReport.txt', 'text/plain;charset=utf-8'],
    ];
    for (const [fileName, mime] of outputs) {
      const fileArray = window.dotnetRuntime.Module.FS.readFile(fileName);
      const blob = new Blob([fileArray], { type: mime });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = fileName;
      a.click();
      URL.revokeObjectURL(url);
    }
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Extract a List of Word Document Differences</h1>
      <button onClick={extractDifferences}>Extract Differences</button>
    </div>
  );
}

export default App;

The exported list of differences is shown below, with 12 insertions and 8 deletions:

Exported list of differences: one revision per row, the first column is the type and the second is the content, with 12 insertions and 8 deletions

The entries are arranged in the order of the revision collections, with insertions first and deletions after. The preparation date is listed as "Insert 28" and "Delete 15"; the rewrite of the section heading is likewise split into "Delete 6. Confidentiality" and "Insert 6. Data Security"; the bullet marker "●" before the new paragraph and the body of Phase 4 each occupy a row as well.

Object Types and Text Extraction in the Revision Collections

The elements of the revision collections are document objects, which may be TextRange or Paragraph. TextRange.Convert() accepts only text objects and throws a cast error for paragraph objects; paragraphs should be read with Paragraph.Convert() instead. The example takes only text objects according to DocumentObjectType, so that one change occupies only one row:

for (let i = 0; i < collection.Count; i++) {
  const item = collection.get(i);

  // Text object: read the text with TextRange.Convert
  if (item.DocumentObjectType === docModule.DocumentObjectType.TextRange) {
    const text = docModule.TextRange.Convert(item).Text.trim();
  }

  // Paragraph object: read the text with Paragraph.Convert
  if (item.DocumentObjectType === docModule.DocumentObjectType.Paragraph) {
    const paragraphText = docModule.Paragraph.Convert(item).Text.trim();
  }
}

Moving a Paragraph Is Recorded as an Insertion and a Deletion

When a passage is moved forward or backward as a whole, the comparison usually records it as one deletion plus one insertion; in the tests, MoveToRevisions and MoveFromRevisions stay empty. The list is therefore dominated by the insertion and deletion types, and iterating the move collections is mainly a completeness check, so that nothing is missed when move revisions are produced:

// The move collections may be empty; no special check is needed before iterating, since the loop does not run when Count is 0
collect(revisions.MoveToRevisions, 'Move To');
collect(revisions.MoveFromRevisions, 'Move From');

FAQ

Do the files have to be uploaded to a server when comparing Word documents

Not in the usage described in this article. Spire.Doc for JavaScript runs in the browser with WebAssembly, the two documents are read and written locally through the virtual file system (VFS), and loading, comparison and saving all happen locally; the library itself does not send the files to a server. Whether the files eventually leave the device depends on the code you add to the page.

Why do numbers appear run together in the result document, for example "2.6880"

Cause: The tracked change places the deleted "68" and the inserted "80" next to each other at the same position. The deleted part is struck through and the inserted part is underlined, so at a small font size the two are easily read as one number; the "68 months" in the service period is the same case.

Solution: Check the changes one by one with the list of differences. The list splits the same change into two rows, so the old and the new values are clear at a glance:

// The same change is split into two rows in the list
// Delete    68
// Insert    80

Does the comparison require the two documents to have exactly the same formatting

No. The default comparison checks content and formatting together, and differences in fonts, paragraph spacing and so on are recorded as formatting revisions without affecting the identification of text differences. When those differences are not the focus of the review, enable IgnoreFormatting so that the list keeps only the text changes.

const options = new docModule.CompareOptions();
options.IgnoreFormatting = true;
original.Compare({ document: revised, author: 'Contract Review', options });

Why does the result still use the original font after formatting is ignored

Cause: IgnoreFormatting only stops formatting revisions from being recorded; the comparison result is still based on the original, and font adjustments made in the revised document are not written into the result. In the sample, the "7. Other Terms" heading is changed from Arial to Georgia, and with this option enabled the result document still shows Arial.

Solution: When the layout of the revised version has to be reflected in the result, keep the option at its default value and let the formatting differences be kept as revisions:

const options = new docModule.CompareOptions();
// Keep the formatting differences and let the reviewer confirm them one by one
options.IgnoreFormatting = false;
original.Compare({ document: revised, author: 'Contract Review', options });

Why do fragments such as "Delete thre" and "Insert fiv" appear after comparing by character

Cause: Word-by-word comparison records a replacement as whole words, so the change from "three" to "five" is recorded as "Delete three" and "Insert five". Character-level comparison compares character by character; the trailing "e" that is the same before and after is kept as common content, and the remaining "thre" and "fiv" become separate entries. When several lines in the same paragraph are rewritten at the same time, there are more such scattered fragments.

Solution: When the readability of the list matters most, keep the default TextDiffMode.Word; when the difference really has to be located down to a single character, switch to TextDiffMode.Char:

const options = new docModule.CompareOptions();

// Finer granularity: differences are located down to a single character
options.TextCompareLevel = docModule.TextDiffMode.Char;
original.Compare({ document: revised, author: 'Contract Review', options });

Get a Free License

Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.