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.