Most answers to this question are five years old, PDF-only, and start by asking you to measure coordinates against a page you cannot see. Here is the 2026 version: seven calls, the two conventions that decide where the markup actually lands, and the single line a Linux container needs.

Everything below runs on .NET 8 and .NET 10, on Windows and Linux, with no Adobe Acrobat and no Microsoft Office installed — and the same code works on DOCX, XLSX, PPTX and images. Only the file path changes.

The runnable version is on GitHub: annotate-pdf-csharp-net.

Install

dotnet add package GroupDocs.Annotation

Without a licence the library runs in evaluation mode and the output carries evaluation marks. A temporary licence removes them:

new License().SetLicense("GroupDocs.Annotation.lic");

Open the document

using (Annotator annotator = new Annotator("quarterly-review.pdf"))
{
    // add, read or remove markup here
}

One thing to know up front: the annotator keeps the file open until it is disposed, and it cannot save over the file it is reading. Save to a different path, or to a temporary file you move into place afterwards.

Do not measure coordinates — search for the text

This is the part most tutorials skip, and it is the one that makes annotation code survive the next version of the document. GetDocumentInfo() returns every page with its size and its text lines, each with a box:

IDocumentInfo info = annotator.Document.GetDocumentInfo();

foreach (PageInfo page in info.PagesInfo)
{
    foreach (TextLineInfo line in page.TextLines)
    {
        if (line.Text.Contains("Hosting costs"))
        {
            // line.LeftIndent, line.TopIndent, line.Width, line.Height — points, from the top-left
        }
    }
}

Now the annotation can be placed on whatever the document says today, instead of on where it said it last year.

Add a highlight with a comment and a reply

float left   = (float)line.LeftIndent;
float right  = (float)(line.LeftIndent + line.Width);
float top    = (float)(page.Height - line.TopIndent);
float bottom = (float)(page.Height - line.TopIndent - line.Height);

annotator.Add(new HighlightAnnotation
{
    PageNumber = page.PageNumber - 1,
    BackgroundColor = 0xFFE699,
    Opacity = 0.5,
    Message = "Check this against the invoice",
    CreatedOn = DateTime.Now,
    User = new User { Id = 1, Name = "Reviewer", Role = Role.Editor },
    Points = new List<Point>
    {
        new Point(left, top),    new Point(right, top),
        new Point(left, bottom), new Point(right, bottom)
    },
    Replies = new List<Reply>
    {
        new Reply { Comment = "Confirmed with finance.", RepliedOn = DateTime.Now }
    }
});

annotator.Save("result.pdf");

Message is the comment. Replies starts a thread — in a PDF reader it is a note with a response, in Word it is a comment with a reply under it. User is what lets a later pass find and remove exactly this reviewer’s markup and leave everyone else’s alone.

The two conventions that catch everybody

Look closely at the two lines that compute top and bottom, and at PageNumber.

Page numbers are counted twice. PageInfo.PageNumber is 1-based. AnnotationBase.PageNumber is 0-based. Mix them and every annotation is one page off — and the ones meant for the last page vanish, because an annotation addressed to a page that does not exist is dropped on save, with no exception and no warning. If markup “did not appear”, check the page index before anything else.

Two origins. TextLineInfo and AreaAnnotation.Box are measured from the top-left corner. Text markup — highlight, underline, strikeout, squiggly — is measured from the bottom-left, which is how PDF itself works.

Member Origin Page numbering
PageInfo, TextLineInfo top-left 1-based
AnnotationBase.PageNumber — 0-based
AreaAnnotation.Box top-left —
HighlightAnnotation.Points bottom-left —
PreviewOptions.PageNumbers — 1-based

Four points make one rectangle, in this order: top-left, top-right, bottom-left, bottom-right. More rectangles can follow in the same list, which is how a single comment covers a sentence that wraps across three lines.

Read the markup back

using (Annotator annotator = new Annotator("result.pdf"))
{
    foreach (AnnotationBase a in annotator.Get())
    {
        Console.WriteLine($"#{a.Id} {a.Type} on page {a.PageNumber + 1}: {a.Message}");

        foreach (Reply reply in a.Replies)
            Console.WriteLine($"   reply: {reply.Comment}");
    }
}

Get() reads what the document actually contains — including annotations a person added in Word or Acrobat, which is what makes a round trip through other tools work.

One caveat worth knowing: Id is assigned while the document is being read, not stored in it. It is stable enough to delete or update an annotation in the same session, and it is not a key to put in a database. To keep annotations outside the file, use import and export.

Remove it again

annotator.Remove(1);                                      // by id
annotator.Remove(annotator.Get()
    .Where(a => a.User?.Name == "Reviewer").ToList());     // or by author

Render the pages

PreviewOptions previewOptions = new PreviewOptions(n => File.Create($"page_{n}.png"));
previewOptions.PreviewFormat = PreviewFormats.PNG;
previewOptions.PageNumbers = new[] { 1, 2 };     // 1-based
previewOptions.RenderAnnotations = true;
previewOptions.Resolution = 144;

annotator.Document.GeneratePreview(previewOptions);

Useful in three places: a web UI that shows the document, a test that proves the markup landed where you meant it to, and an agent that needs to look at its own work.

On Linux

The code does not change. The container does:

FROM mcr.microsoft.com/dotnet/runtime:8.0
RUN apt-get update \
 && apt-get install -y --no-install-recommends fontconfig fonts-liberation \
 && rm -rf /var/lib/apt/lists/*

The .NET runtime images ship without fonts and text rendering needs them. That single line is the difference between correct output and blank pages — and it is the most common Linux question about document libraries in general.

Where to go next

git clone https://github.com/groupdocs-annotation/annotate-pdf-csharp-net.git
cd annotate-pdf-csharp-net
dotnet run --project src/AnnotatePdf -- highlight samples/quarterly-review.pdf --text "Hosting costs"