Comments
Reviewers leave remarks in a document as comments: a note on a piece of text, with its author, the time it was written and replies from other reviewers. When such a document reaches the server, your code reads the comments into a report, adds comments of its own to text it finds and removes all comments before the document goes to a customer.
Commenting in the browser is described in Comments in the Document Editor section. The examples below are console programs that load Documents/review.docx, a document with comments.
How Comments Work
A comment is a CommentedText object: the commented piece of text together with the comment. Its properties describe both:
| Property | Contains |
|---|---|
| Comment | The text of the comment; it can be changed |
| Text, Start, Length | The commented text, the one-based position of its first character and its number of characters |
| UserName | The author; null or an empty string for an unknown author |
| CreationTime | The time the comment was written, in UTC |
| Number | The position of the comment in the text, starting with 1 |
| Replies, RepliedComment | The replies to the comment and the comment it replies to, each null if there is none |
Keep these points in mind:
- Replies are comments too. A reply belongs to the same piece of text as the comment it answers, but it can have another author and time. Replies are contained in the collection like all other comments, so check
RepliedCommentto tell them apart. - One collection per text part. ServerTextControl.Comments returns the CommentCollection of the main text. Headers, footers and text frames have their own, IFormattedText.Comments.
- Formats. Comments are saved and loaded with DOCX, RTF and the TX Text Control format, and with DOC on the Classic engine. They're compatible with the comments of MS Word.
- Comments on tracked changes. A comment attached to a tracked change is removed when the change is accepted or rejected, as Track Changes describes.
A DOCX document with comments looks like this in MS Word: each comment is attached to a piece of text, and the first comment has a reply. The blog article Extracting Comments from DOCX Files in .NET C# reads this document with ServerTextControl:

List Comments and Their Replies
The following program prints each comment of the main text with its author, time and commented text, followed by its replies:
using TXTextControl;
using (ServerTextControl tx = new ServerTextControl())
{
tx.Create();
tx.Load("Documents/review.docx", StreamType.WordprocessingML);
// The collection contains the comments and their replies
Console.WriteLine($"Comments and replies: {tx.Comments.Count}");
foreach (CommentedText comment in tx.Comments)
{
// Replies are printed below the comment they answer
if (comment.RepliedComment != null)
{
continue;
}
Console.WriteLine($"{AuthorOf(comment)} ({comment.CreationTime:yyyy-MM-dd HH:mm} UTC) on \"{comment.Text}\": {comment.Comment}");
if (comment.Replies != null)
{
foreach (CommentedText reply in comment.Replies)
{
Console.WriteLine($" {AuthorOf(reply)}: {reply.Comment}");
}
}
}
}
// UserName is null or empty for an unknown author
static string AuthorOf(CommentedText comment) =>
string.IsNullOrEmpty(comment.UserName) ? "Unknown author" : comment.UserName;
The loop skips the replies, because each reply is also printed through the Replies of its comment; without the check, every reply would appear twice. The blog article Extracting Comments from DOCX Files in .NET C# filters comments by author and date with LINQ.
Add a Comment
A comment is added to a range of text given by its position and length. The following program finds a date in a contract and comments on it:
using TXTextControl;
using (ServerTextControl tx = new ServerTextControl())
{
tx.Create();
tx.Load("Documents/contract.docx", StreamType.WordprocessingML);
string searchText = "December 31";
// Find returns a zero-based position, or -1
int position = tx.Find(searchText);
if (position == -1)
{
Console.WriteLine($"\"{searchText}\" wasn't found.");
return;
}
// The CommentedText constructor expects a one-based start position
CommentedText comment = new CommentedText("Please confirm the date.", position + 1, searchText.Length);
CommentCollection.AddResult result = tx.Comments.Add(comment);
Console.WriteLine($"Comment added: {result}");
tx.Save("Documents/contract-commented.docx", StreamType.WordprocessingML);
}
The code works as follows:
- The CommentedText constructor takes the comment, the one-based start position and the length of the commented text. A constructor with only the comment uses the current selection, and a constructor with a
CommentedTextas the second parameter creates a reply to that comment. - CommentCollection.Add returns an AddResult:
PositionInvalidfor an invalid position or length,SelectionTooComplexwhen the text isn't a continuous sequence of characters,NoSelectionwhen no text is selected,Errorfor an unexpected error and otherwiseSuccessful.
Reply to a Comment
A reply is a CommentedText created with the comment it answers as the second parameter, and added to the same collection. The following program answers the first comment of Documents/review.docx that isn't a reply itself and changes the text of that comment:
using TXTextControl;
using (ServerTextControl tx = new ServerTextControl())
{
tx.Create();
tx.Load("Documents/review.docx", StreamType.WordprocessingML);
foreach (CommentedText comment in tx.Comments)
{
// Answer a comment, not a reply
if (comment.RepliedComment != null)
{
continue;
}
CommentedText reply = new CommentedText("Checked on the server.", comment);
CommentCollection.AddResult result = tx.Comments.Add(reply);
Console.WriteLine($"Reply added: {result}");
// Comment can be changed directly
comment.Comment += " (reviewed)";
break;
}
tx.Save("Documents/review-answered.docx", StreamType.WordprocessingML);
}
The loop ends with break after the first comment, because adding a reply changes the collection. EditComment and ReplyToComment do the same through the user interface: they open a comment input control in which a user edits the comment or writes a reply, and return false if it can't be opened. ReplyToComment also returns false for a comment that is a reply itself. Without a user, set Comment and use the constructor as above.
Export Comments to a Report
Collecting the comments of every part of a document gives a review report that another application can process. The following program writes the comments of all text parts with their replies to comments.json:
using System.Text.Json;
using TXTextControl;
List<object> report = new List<object>();
using (ServerTextControl tx = new ServerTextControl())
{
tx.Create();
tx.Load("Documents/review.docx", StreamType.WordprocessingML);
// The main text, text frames, headers and footers
foreach (IFormattedText textPart in tx.TextParts)
{
foreach (CommentedText comment in textPart.Comments)
{
if (comment.RepliedComment != null)
{
continue;
}
CommentedText[] replies = comment.Replies ?? Array.Empty<CommentedText>();
report.Add(new
{
Part = textPart.GetType().Name,
Author = comment.UserName,
Created = comment.CreationTime,
Text = comment.Text,
Comment = comment.Comment,
Replies = replies
.Select(reply => new { Author = reply.UserName, Created = reply.CreationTime, Comment = reply.Comment })
.ToList()
});
}
}
}
string json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText("comments.json", json);
Console.WriteLine(json);
The values are copied into new objects inside the using block, while the ServerTextControl instance that the comments belong to still exists. ServerTextControl.TextParts contains the main text, the text frames and the headers and footers.
Remove Comments Before Sending a Document
The following program removes the comments of one author and then all remaining comments of all text parts before it saves the document:
using TXTextControl;
using (ServerTextControl tx = new ServerTextControl())
{
tx.Create();
tx.Load("Documents/review.docx", StreamType.WordprocessingML);
tx.CommentDeleted += (sender, e) =>
Console.WriteLine($"Removed: \"{e.CommentedText.Comment}\" by {e.CommentedText.UserName}");
// Removing a comment removes its replies too
string author = "Tim Typer";
while (true)
{
CommentedText? comment = tx.Comments.Cast<CommentedText>()
.FirstOrDefault(c => c.RepliedComment == null && c.UserName == author);
if (comment == null || !tx.Comments.Remove(comment))
{
break;
}
}
// Remove all remaining comments of all text parts
foreach (IFormattedText textPart in tx.TextParts)
{
while (textPart.Comments.Count > 0)
{
if (!textPart.Comments.Remove(textPart.Comments[1]))
{
break;
}
}
}
tx.Save("Documents/final.docx", StreamType.WordprocessingML);
}
The code works as follows:
- CommentCollection.Remove removes the comment and its replies and returns
trueif it was removed. The commented text stays in the document. - The program searches the collection again after each removal, and the indexer Item with 1 always returns the first remaining comment.
- CommentDeleted occurs for each removed comment. In the handler, only
Comment,UserName,CreationTime,IDand the highlight settings of the comment are valid. CommentCreated occurs when text has been commented.
To keep the comments in your document and leave them out of a copy only, save the copy with SaveSettings.OmittedContent set to OmittedContent.Comments, as Saving and Converting Documents shows. This setting can't be used for PDF.
Next Steps
- Track Changes: process the tracked changes that reviewers made.
- Comments in the Document Editor: let users add and answer comments.
- Text: find the text to comment on.