Build an annotation UI for your web app

GroupDocs.Annotation is UI-agnostic: it reads and writes markup inside documents and draws nothing on a screen. That is the right split for a server library, and it leaves one question for everyone who adopts it — how does a person actually draw the annotation?

This page describes a front end that needs no viewer component, no Angular package and no third-party PDF renderer: page images, a canvas, and four endpoints.

Note
A runnable implementation — ASP.NET Core minimal API plus about 250 lines of plain JavaScript — is on GitHub: browser-annotation-ui-net. Clone it, dotnet run, and drag a box on the page.

The design in one paragraph

The server renders each page once as a PNG without annotations and caches it. The browser shows that image and draws the markup itself, on a canvas on top, from the annotation list the server returns as data. Adding a comment is a small write to the document plus a reload of the list — not a re-render of the page. Annotations stay selectable, clickable and answerable instead of being baked into a picture.

The endpoints

MethodRoutePurpose
GET/api/documents/{name}/infopage count and the size of every page, in points
GET/api/documents/{name}/pages/{n}/imagePNG of page n
GET/api/documents/{name}/annotationsevery annotation as one rectangle in page units
POST/api/documents/{name}/annotationsadd one
PUT/api/documents/{name}/annotations/{id}change the message or append a reply
DELETE/api/documents/{name}/annotations/{id}remove one

Every write returns the full annotation list, so the browser never has to reason about what the server now holds.

Step 1: render the pages

using GroupDocs.Annotation;
using GroupDocs.Annotation.Options;

using Annotator annotator = new Annotator(path);

PreviewOptions previewOptions = new PreviewOptions(page => File.Create(CachePath(name, page)))
{
    PreviewFormat = PreviewFormats.PNG,
    PageNumbers = new[] { pageNumber },   // 1-based
    RenderAnnotations = false,            // the canvas draws the markup
    Resolution = 144
};

annotator.Document.GeneratePreview(previewOptions);

Cache the result and compare its timestamp against the document’s: a page is re-rendered only after the document actually changes. See Generate document pages preview for resolution, worksheet columns and the stream callbacks.

Step 2: publish the page geometry

The browser needs the page size to map what it draws back to the document:

IDocumentInfo info = annotator.Document.GetDocumentInfo();

var pages = info.PagesInfo
    .Select(page => new { number = page.PageNumber, width = page.Width, height = page.Height })
    .ToList();

PageInfo.Width and Height are in points. PageInfo.PageNumber is 1-based.

Step 3: capture a rectangle in the browser

The canvas sits on top of the image at the same size. On pointerdown / pointermove / pointerup, keep the rectangle in image pixels and send it together with the size of the image it was drawn on:

await fetch(`/api/documents/${name}/annotations`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        page: currentPage,                  // 1-based
        x, y, width, height,                // pixels on the displayed image
        imageWidth: img.clientWidth,
        imageHeight: img.clientHeight,
        message: window.prompt('Comment'),
        user: 'Reviewer'
    })
});

Sending the image size instead of a scale factor is what keeps the UI correct when the preview resolution changes, when the image is scaled by CSS, or when the page is opened on a phone.

Step 4: map pixels to page units and store the annotation

PageInfo page = info.PagesInfo[request.Page - 1];

double scaleX = page.Width / request.ImageWidth;
double scaleY = page.Height / request.ImageHeight;

annotator.Add(new AreaAnnotation
{
    PageNumber = page.PageNumber - 1,      // PageInfo is 1-based, annotations are 0-based
    Box = new Rectangle(
        (float)(request.X * scaleX),
        (float)(request.Y * scaleY),
        (float)(request.Width * scaleX),
        (float)(request.Height * scaleY)),
    BackgroundColor = 0xFFE699,
    PenColor = 0xE08C00,
    PenWidth = 1,
    Opacity = 0.35,
    CreatedOn = DateTime.Now,
    Message = request.Message,
    User = new User { Id = 1, Name = request.User, Role = Role.Editor }
});

AreaAnnotation.Box is measured from the top-left corner of the page, which is the same direction the browser measures in — so a box drawn on the canvas maps across with a scale and no flip.

Step 5: send one shape of rectangle to the browser

Text markup does not share that origin. HighlightAnnotation.Points and the other text-markup types are measured from the bottom-left corner. Convert them on the server, so the front end only ever sees top-left boxes:

if (annotation is AreaAnnotation area)
{
    x = area.Box.X; y = area.Box.Y;
    width = area.Box.Width; height = area.Box.Height;
}
else if (annotation is HighlightAnnotation highlight)
{
    double minX = highlight.Points.Min(p => (double)p.X);
    double maxX = highlight.Points.Max(p => (double)p.X);
    double minY = highlight.Points.Min(p => (double)p.Y);
    double maxY = highlight.Points.Max(p => (double)p.Y);

    x = minX;
    y = page.Height - maxY;                // flip to top-left
    width = maxX - minX;
    height = maxY - minY;
}

This is what makes a highlight added in Word, in Acrobat or by a machine review pass appear in the right place in your UI.

Step 6: save by swapping files

Annotator holds the source file open until it is disposed and cannot save over the file it is reading. Write next to the original and move the result into place:

using (Annotator annotator = new Annotator(path))
{
    annotator.Add(annotation);
    annotator.Save(path + ".tmp");
}

File.Move(path + ".tmp", path, overwrite: true);
InvalidatePreviewCache(name);

Take a per-document lock around the whole sequence. Two requests that read, change and rewrite the same file concurrently will lose one of the changes.

Replies and updates

AnnotationBase annotation = annotator.Get().First(a => a.Id == id);

List<Reply> replies = annotation.Replies?.ToList() ?? new List<Reply>();
replies.Add(new Reply
{
    Comment = text,
    RepliedOn = DateTime.Now,
    User = new User { Id = 2, Name = author, Role = Role.Editor }
});

annotation.Replies = replies;
annotator.Update(annotation);
Warning
Id is assigned while the document is read, not stored in it, and it changes after a write. It is safe to use within one request — read, find, update, save — and it is not a key to hand out. A UI that reloads the list after every change is fine; a multi-user system should keep annotations in its own table and use Import annotations and Export annotations to move them in and out of the file.

Where to go beyond the sample

  • Text selection. Use the text lines from GetDocumentInfo to let a user select words rather than drag a box, and add a HighlightAnnotation instead of an area.
  • Large documents. Render pages on demand and cache them; the sample rewrites the whole document on every change, which is fine for a contract and wrong for an archive.
  • Several reviewers. Set User per request and filter by author when rendering, so everyone can hide everyone else’s markup.
  • A third-party viewer instead. If you already embed a JavaScript PDF viewer, keep it for display and use these endpoints only for storage — the geometry rules above do not change.
Close
Loading

Analyzing your prompt, please hold on...

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