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

The usual answers are heavy: embed a commercial viewer, or adopt the Angular demo and then try to extend it. There is a lighter one. Page images, a canvas, and six endpoints — no viewer component, no front-end framework, no build step, no CDN. It also runs with no outbound network access at all, which matters more than it used to.

The sample is on GitHub: browser-annotation-ui-net — dotnet run and drag a box on the page.

The idea in one paragraph

Render each page once as a PNG without annotations and cache it. Send the annotation list to the browser as data. The canvas draws the markup on top of the image. Adding a comment is then a small write to the document plus a reload of the list — not a re-render of the page — and the markup stays selectable, clickable and answerable instead of being baked into a picture.

The endpoints

Method Route Purpose
GET /api/documents/{name}/info page count and the size of every page, in points
GET /api/documents/{name}/pages/{n}/image PNG of page n
GET /api/documents/{name}/annotations every annotation as one rectangle in page units
POST /api/documents/{name}/annotations add one
PUT /api/documents/{name}/annotations/{id} change the message or append a reply
DELETE /api/documents/{name}/annotations/{id} remove one

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

Rendering

PreviewOptions previewOptions = new(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);

Compare the cached image’s timestamp against the document’s and re-render only when the document actually changed.

Pixels in, page units out

The canvas keeps its rectangle in image pixels and sends the size of the image it drew on — not a scale factor:

body: JSON.stringify({
    page: currentPage,
    x, y, width, height,
    imageWidth: img.clientWidth,
    imageHeight: img.clientHeight,
    message: window.prompt('Comment'),
    user: 'Reviewer'
})

That one decision is what keeps the UI correct when the preview resolution changes, when CSS scales the image, or when someone opens it on a phone. The server does the arithmetic:

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,
    Opacity = 0.35,
    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 — the same direction the browser measures in — so a drawn box maps across with a scale and no flip.

Send one shape of rectangle to the browser

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

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

Do this and a highlight added in Word, in Acrobat, or by an automated review pass appears in the right place in your UI without the JavaScript knowing anything about PDF geometry.

Writes swap files

The annotator holds the source open and cannot save over the file it is reading:

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 at once will lose one of the changes.

What this design does not solve

Worth saying plainly, because it decides whether you can ship it as it stands:

  • Annotation ids change after a write. They are assigned while the document is read, not stored in it. 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 and export to move them in and out of the file — which is also how you get an audit trail and per-user visibility.
  • The whole document is rewritten on every change. Fine for a 30-page contract, wrong for a 2,000-page archive.
  • There is no authentication. Every caller in the sample is “Reviewer”.

Run it

git clone https://github.com/groupdocs-annotation/browser-annotation-ui-net.git
cd browser-annotation-ui-net
dotnet run --project src/AnnotationUi

Or in a container, with --network none if you want to watch it work with no egress at all:

docker build -t annotation-ui .
docker run --rm -p 8080:8080 annotation-ui

The step-by-step version is in the documentation: Build an annotation UI for your web app. If you already embed a JavaScript PDF viewer, keep it for display and use these endpoints only for storage — none of the geometry rules change.