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
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
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
usingGroupDocs.Annotation;usingGroupDocs.Annotation.Options;usingAnnotatorannotator=newAnnotator(path);PreviewOptionspreviewOptions=newPreviewOptions(page=>File.Create(CachePath(name,page))){PreviewFormat=PreviewFormats.PNG,PageNumbers=new[]{pageNumber},// 1-basedRenderAnnotations=false,// the canvas draws the markupResolution=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:
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:
awaitfetch(`/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
PageInfopage=info.PagesInfo[request.Page-1];doublescaleX=page.Width/request.ImageWidth;doublescaleY=page.Height/request.ImageHeight;annotator.Add(newAreaAnnotation{PageNumber=page.PageNumber-1,// PageInfo is 1-based, annotations are 0-basedBox=newRectangle((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=newUser{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(annotationisAreaAnnotationarea){x=area.Box.X;y=area.Box.Y;width=area.Box.Width;height=area.Box.Height;}elseif(annotationisHighlightAnnotationhighlight){doubleminX=highlight.Points.Min(p=>(double)p.X);doublemaxX=highlight.Points.Max(p=>(double)p.X);doubleminY=highlight.Points.Min(p=>(double)p.Y);doublemaxY=highlight.Points.Max(p=>(double)p.Y);x=minX;y=page.Height-maxY;// flip to top-leftwidth=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:
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.
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.