Annotate a PDF in C#

GroupDocs.Annotation for .NET adds markup to a document and saves it back in its own format. There is no viewer to embed and no Adobe Acrobat or Microsoft Office to install: it is a library that reads a file, adds annotations, and writes the file out again — on Windows, on Linux and in a container.

This page is the shortest complete path through it: open a PDF, highlight a line, attach a comment with a reply, read the markup back out, remove it, and render page images. The same code runs unchanged on DOCX, XLSX, PPTX and images — only the file path changes.

Note
A runnable version of everything on this page is on GitHub: annotate-pdf-csharp-net. Clone it and run dotnet run --project src/AnnotatePdf -- info samples/quarterly-review.pdf.

Before you start

  • .NET 8.0 or later (the library also supports .NET Framework 4.6.2 and .NET 6).
  • The package from NuGet:
dotnet add package GroupDocs.Annotation
Install-Package GroupDocs.Annotation

Without a licence the library runs in evaluation mode and the output carries evaluation marks. Apply one at start-up — see Licensing:

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

Step 1: open the document

Everything starts with an Annotator. It keeps the file open until it is disposed, so wrap it in a using block — and note that it cannot save over the file it is reading. Save to a different path, or to a temporary file you move into place afterwards.

using GroupDocs.Annotation;

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

Step 2: find the text you want to mark up

Coordinates can be hard-coded, but they rarely survive the next version of the document. GetDocumentInfo() returns every page with its size and its text lines, each with a box, so the phrase can be located instead of measured:

using GroupDocs.Annotation;
using GroupDocs.Annotation.Models;

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 - in points, from the TOP-left
        }
    }
}

PageInfo.Width and PageInfo.Height are the page size in points; TextLineInfo gives the box of one line, measured from the top-left corner of the page.

Step 3: add a highlight with a comment and a reply

HighlightAnnotation.Points describes one or more rectangles. Four points make one rectangle, in this order: top-left, top-right, bottom-left, bottom-right. The y values are measured from the bottom-left corner — the convention PDF itself uses — so a line found in step 2 has to be flipped:

using System;
using System.Collections.Generic;
using GroupDocs.Annotation.Models;
using GroupDocs.Annotation.Models.AnnotationModels;

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);

HighlightAnnotation highlight = new HighlightAnnotation
{
    PageNumber = page.PageNumber - 1,          // PageInfo is 1-based, annotations are 0-based
    BackgroundColor = 0xFFE699,                // 0xRRGGBB
    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.Add(highlight);

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

Page numbers and origins

Three of the four numbers above come from different conventions. Mixing them is the usual reason markup appears on the wrong page or in the wrong place:

MemberOriginPage numbering
PageInfo.PageNumber, TextLineInfotop-left1-based
AnnotationBase.PageNumber (every annotation type)—0-based
AreaAnnotation.Box, PointAnnotation.Boxtop-left—
HighlightAnnotation.Points and other text markupbottom-left—
PreviewOptions.PageNumbers—1-based
Warning
An annotation whose PageNumber is past the last page is dropped when the document is saved — no exception is raised and no markup appears. If an annotation “did not show up”, check the page index before anything else.

Step 4: save

annotator.Save("result.pdf");

Save writes the markup into the document in its own native form: real PDF annotations in a PDF, real comments and highlighting in a Word file. Anyone can open the result in Acrobat, in Word or in a browser without your application.

Pass SaveOptions to write only some annotation types, only annotated pages, or a page range.

Step 5: read the markup back

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

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

Get() reads the annotations the document actually contains, including ones a person added in Word or Acrobat. Get(AnnotationType) narrows the result to one type.

Note
Id is assigned while the document is being read. It is stable for as long as the file does not change, which is enough to delete or update an annotation in the same session — but it is not a key to store in a database. To keep annotations outside the document, use Export annotations and Import annotations.

Step 6: remove markup

using (Annotator annotator = new Annotator("result.pdf"))
{
    annotator.Remove(1);                       // by id
    annotator.Save("clean.pdf");
}

To remove one reviewer’s markup and leave everyone else’s in place, filter by author:

List<AnnotationBase> theirs = annotator
    .Get()
    .Where(a => a.User?.Name == "Reviewer")
    .ToList();

annotator.Remove(theirs);

Step 7: render the pages

Page images are what a web front end displays, and what an AI agent looks at to check its own work:

using GroupDocs.Annotation.Options;

PreviewOptions previewOptions = new PreviewOptions(pageNumber =>
    File.Create($"page_{pageNumber}.png"));

previewOptions.PreviewFormat = PreviewFormats.PNG;
previewOptions.PageNumbers = new[] { 1, 2 };   // 1-based
previewOptions.RenderAnnotations = true;       // false renders the clean page
previewOptions.Resolution = 144;

annotator.Document.GeneratePreview(previewOptions);

See Generate document pages preview for worksheet columns, resolution and the stream callbacks.

Running on Linux

Nothing changes in the code. The one thing to remember is fonts: the .NET runtime images ship without them, and text rendering needs them.

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/*

A document that uses other fonts needs those fonts installed, or pointed at with LoadOptions.FontDirectories.

Close
Loading

Analyzing your prompt, please hold on...

An error occurred while retrieving the results. Please refresh the page and try again.