Creating custom annotations for your viewer

WebViewer allows you to create your own annotations that can be customized in several different ways. You can change the appearance and behaviors of the annotation, selection box, and control handles. As an example of this, we're going to walk through the steps to create a custom triangle annotation.

To save annotations in WebViewer, they must output XFDF that meets the XFDF specification in order to function properly in compliant viewers. Since this is a custom annotation, it would not render and behave the same in another viewer as it will not conform to the XFDF specfication. Nor will the viewer have the custom logic to handle it. However, WebViewer can automatically handle converting the custom annotation to a stamp annotation (and vice-versa) to preserve the appearance of the page as much as possible. All custom rendering and behavior will only work in WebViewer, where you have custom logic to handle it.

Creating the custom annotation class

First let's create a basic triangle annotation class.

1WebViewer(
2 // ...
3).then(function(instance) {
4 const { Annotations } = instance.Core;
5
6 class TriangleAnnotation extends Annotations.CustomAnnotation {
7 constructor() {
8 super('triangle'); // provide the custom XFDF element name
9 this.Subject = 'Triangle';
10 }
11 }
12
13 // this is necessary to set the elementName before instantiation
14 TriangleAnnotation.prototype.elementName = 'triangle';
15});

We'll have it inherit from Annotations.CustomAnnotation and set the XFDF element name to triangle. The element name is what's used for the annotation's XML element in the XFDF. Notice that triangle is not in the XFDF specification so this normally would not work. By inheriting from the CustomAnnotation class, the annotation will be able to automatically take advantage of saving as a stamp when downloading the document. This will allow the custom annotation to appear similar to how it appears in WebViewer in another viewer.

Next, let's define the draw function on the class so that the annotation knows how to render itself. The draw function takes a canvas context and is called whenever the annotation should be drawn.

JavaScript

1class TriangleAnnotation extends Annotations.CustomAnnotation {
2 // ...
3 draw(ctx, pageMatrix) {
4 // the setStyles function is a function on markup annotations that sets up
5 // certain properties for us on the canvas for the annotation's stroke thickness.
6 this.setStyles(ctx, pageMatrix);
7
8 // first we need to translate to the annotation's x/y coordinates so that it's
9 // drawn in the correct location
10 ctx.translate(this.X, this.Y);
11 ctx.beginPath();
12 ctx.moveTo(this.Width / 2, 0);
13 ctx.lineTo(this.Width, this.Height);
14 ctx.lineTo(0, this.Height);
15 ctx.closePath();
16 ctx.fill();
17 ctx.stroke();
18 }
19}

Lastly, we want to register our annotation type so that the AnnotationManager recognizes our custom type when reading and outputting XFDF.

1const { annotationManager } = instance.Core;
2
3// ...
4
5// register the annotation type so that it can be saved to XFDF files
6annotationManager.registerAnnotationType(TriangleAnnotation.prototype.elementName, TriangleAnnotation);

Adding the annotation to a document

Although we can programmatically create and add this annotation, it would not be intuitive for regular users. To allow a user to actually add the annotation to a document, we'll need to create a tool so that the user can use to create our annotation through the UI. Our triangle just depends on two mouse points so we can inherit from the GenericAnnotationCreateTool which handles that for us.

1// we also need to access the Tools namespace from the instance
2const { Annotations, Tools } = instance.Core;
3
4// ...
5
6class TriangleCreateTool extends Tools.GenericAnnotationCreateTool {
7 constructor(documentViewer) {
8 // TriangleAnnotation is the class (function) for our annotation we defined previously
9 super(documentViewer, TriangleAnnotation);
10 }
11};

With our tool created we can add a button to the UI so that it can be switched to.

1// access annotationManager and documentViewer objects from the instance
2const { Annotations, Tools, annotationManager, documentViewer } = instance.Core;
3
4// ...
5
6const triangleToolName = 'AnnotationCreateTriangle';
7
8const triangleTool = new TriangleCreateTool(documentViewer);
9instance.UI.registerTool({
10 toolName: triangleToolName,
11 toolObject: triangleTool,
12 buttonImage: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">' +
13 '<path d="M12 7.77L18.39 18H5.61L12 7.77M12 4L2 20h20L12 4z"/>' +
14 '<path fill="none" d="M0 0h24v24H0V0z"/>' +
15 '</svg>',
16 buttonName: 'triangleToolButton',
17 tooltip: 'Triangle'
18}, TriangleAnnotation);
19
20instance.UI.setHeaderItems((header) => {
21 header.getHeader('toolbarGroup-Shapes').get('freeHandToolGroupButton').insertBefore({
22 type: 'toolButton',
23 toolName: triangleToolName
24 });
25});
26
27documentViewer.addEventListener('documentLoaded', () => {
28 // set the tool mode to our tool so that we can start using it right away
29 instance.UI.setToolMode(triangleToolName);
30});

At this point you should see a new button in the toolbar with a triangle icon, and the new triangle tool should be automatically selected. Clicking and dragging on the document should create a triangle annotation.

Apryse Docs Image

After creating some triangles you might notice that the selection box is a rectangle and has eight control handles. This isn't terrible but we could probably make it better by having a control handle for each corner and drawing the selection box around the edges of the annotation.

Making customizable vertices

First, we will add a property on the annotation that takes an array of vertices which can be adjusted individually by a user moving the control points. Then define a new selection model and control handles to resize the annotation. A SelectionModel defines the selection behavior of the annotation.

We'll add the array to the annotation constructor:

1// you can also get Core from the instance
2const { Core } = instance;
3
4class TriangleAnnotation extends Annotations.CustomAnnotation {
5 constructor() {
6 super('triangle'); // Provide the custom XFDF element name
7 this.Subject = 'Triangle';
8 // create simple property
9 this.vertices = [];
10 const numVertices = 3;
11 // initialize points
12 for (let i = 0; i < numVertices; ++i) {
13 this.vertices.push(new Core.Math.Point());
14 }
15 }
16}

Then we'll update the draw function on the annotation to use the vertices:

JavaScript

1class TriangleAnnotation extends Annotations.CustomAnnotation {
2 // ...
3 draw(ctx, pageMatrix) {
4 // the setStyles function is a function on markup annotations that sets up
5 // certain properties for us on the canvas for the annotation's stroke thickness.
6 this.setStyles(ctx, pageMatrix);
7
8 // draw the triangle lines using vertices from our list
9 ctx.beginPath();
10 ctx.moveTo(this.vertices[0].x, this.vertices[0].y);
11 ctx.lineTo(this.vertices[1].x, this.vertices[1].y);
12 ctx.lineTo(this.vertices[2].x, this.vertices[2].y);
13 ctx.closePath();
14 ctx.fill();
15 ctx.stroke();
16 }
17}

Then for the tool we'll override the mouseMove function to set the vertices on creation:

JavaScript

1class TriangleCreateTool extends Tools.GenericAnnotationCreateTool {
2 // ...
3 mouseMove(e) {
4 // call the parent mouseMove first
5 super.mouseMove(e);
6 if (this.annotation) {
7 // set the vertices relative to the annotation width and height
8 this.annotation.vertices[0].x = this.annotation.X + this.annotation.Width / 2;
9 this.annotation.vertices[0].y = this.annotation.Y;
10 this.annotation.vertices[1].x = this.annotation.X + this.annotation.Width;
11 this.annotation.vertices[1].y = this.annotation.Y + this.annotation.Height;
12 this.annotation.vertices[2].x = this.annotation.X;
13 this.annotation.vertices[2].y = this.annotation.Y + this.annotation.Height;
14
15 // update the annotation appearance
16 annotationManager.redrawAnnotation(this.annotation);
17 }
18 }
19}

Creating a custom selection model

At this point the drawing of the annotation should look the same as before, however you won't be able to move the annotation. To fix this, let us create the custom selection model and control handles. Since the selection model needs us to define which type of control handles are used, we will start by defining the custom control handles.

1class TriangleControlHandle extends Annotations.ControlHandle {
2 constructor(annotation, index) {
3 super();
4 this.annotation = annotation;
5 // set the index of this control handle so that we know which vertex it corresponds to
6 this.index = index;
7 }
8 // returns a rect that should represent the control handle's position and size
9 getDimensions(annotation, selectionBox, zoom) {
10 let x = annotation.vertices[this.index].x;
11 let y = annotation.vertices[this.index].y;
12 // account for zoom level
13 const width = Annotations.ControlHandle.handleWidth / zoom;
14 const height = Annotations.ControlHandle.handleHeight / zoom;
15
16 // adjust for the control handle's own width and height
17 x -= width * 0.5;
18 y -= height * 0.5;
19 return new Core.Math.Rect(x, y, x + width, y + height);
20 }
21 // this function is called when the control handle is dragged
22 move(annotation, deltaX, deltaY, fromPoint, toPoint) {
23 annotation.vertices[this.index].x += deltaX;
24 annotation.vertices[this.index].y += deltaY;
25
26 // recalculate the X, Y, width and height of the annotation
27 let minX = Number.MAX_VALUE;
28 let maxX = -Number.MAX_VALUE;
29 let minY = Number.MAX_VALUE;
30 let maxY = -Number.MAX_VALUE;
31 for (let i = 0; i < annotation.vertices.length; ++i) {
32 const vertex = annotation.vertices[i];
33 minX = Math.min(minX, vertex.x);
34 maxX = Math.max(maxX, vertex.x);
35 minY = Math.min(minY, vertex.y);
36 maxY = Math.max(maxY, vertex.y);
37 }
38
39 const rect = new Annotations.Rect(minX, minY, maxX, maxY);
40 annotation.setRect(rect);
41 // return true if redraw is needed
42 return true;
43 }
44}

Then we can define our selection model that use our custom control point.

JavaScript

1// selection model creates the necessary control handles
2class TriangleSelectionModel extends Annotations.SelectionModel {
3 constructor(annotation, canModify) {
4 super(annotation, canModify);
5 if (canModify) {
6 const controlHandles = this.getControlHandles();
7 // pass the vertex index to each control handle
8 controlHandles.push(new TriangleControlHandle(annotation, 0));
9 controlHandles.push(new TriangleControlHandle(annotation, 1));
10 controlHandles.push(new TriangleControlHandle(annotation, 2));
11 }
12 }
13}

Finally, we can assign this new selection model as the selection model for our triangle. Notice we assign the class instead of an instance since this the selection model is dynamically created.

JavaScript

1class TriangleAnnotation extends Annotations.CustomAnnotation {
2 constructor() {
3 // ...
4 this.selectionModel = TriangleSelectionModel;
5 }
6}

Now there should be a control handle for each point of the triangle and if you drag them around you'll move that vertex of the triangle! However you may notice that if you try to drag and move the annotation it won't work. To fix this let's override the resize function on the annotation.

JavaScript

1class TriangleAnnotation extends Annotations.CustomAnnotation {
2 // ...
3 resize(rect) {
4 // this function is only called when the annotation is dragged
5 // since we handle the case where the control handles move
6 const annotRect = this.getRect();
7 // determine how much change in each dimension
8 const deltaX = rect.x1 - annotRect.x1;
9 const deltaY = rect.y1 - annotRect.y1;
10
11 // shift the vertices by the amount the rect has shifted
12 this.vertices = this.vertices.map((vertex) => {
13 vertex.translate(deltaX, deltaY);
14 return vertex;
15 });
16 this.setRect(rect);
17 }
18}

Next, let's change the selection box so that it's displayed around the sides of the triangle. We'll do this by overriding the drawSelectionOutline function on the selection model.

JavaScript

1class TriangleSelectionModel extends Annotations.SelectionModel {
2 // ...
3 // changes how we draw the selection outline
4 drawSelectionOutline(ctx, annotation, zoom) {
5 // adjust for zoom
6 if (typeof zoom !== 'undefined') {
7 ctx.lineWidth = Annotations.SelectionModel.selectionOutlineThickness / zoom;
8 } else {
9 ctx.lineWidth = Annotations.SelectionModel.selectionOutlineThickness;
10 }
11
12 // changes the selection outline color if the user doesn't have permission to modify this annotation
13 if (this.canModify()) {
14 ctx.strokeStyle = Annotations.SelectionModel.defaultSelectionOutlineColor.toString();
15 } else {
16 ctx.strokeStyle = Annotations.SelectionModel.defaultNoPermissionSelectionOutlineColor.toString();
17 }
18
19 ctx.beginPath();
20 ctx.moveTo(annotation.vertices[0].x, annotation.vertices[0].y);
21 ctx.lineTo(annotation.vertices[1].x, annotation.vertices[1].y);
22 ctx.lineTo(annotation.vertices[2].x, annotation.vertices[2].y);
23 ctx.closePath();
24 ctx.stroke();
25
26 // draw a dashed line around the triangle
27 const dashUnit = Annotations.SelectionModel.selectionOutlineDashSize / zoom;
28 const sequence = [dashUnit, dashUnit];
29 ctx.setLineDash(sequence);
30 ctx.strokeStyle = 'rgb(255, 255, 255)';
31 ctx.stroke();
32 }
33 // change the selection testing to match the shape of the triangle
34 testSelection(annotation, x, y, pageMatrix) {
35 // the canvas visibility test will only select the annotation
36 // if a user clicks exactly on it as opposed to the rectangular bounding box
37 return Annotations.SelectionAlgorithm.canvasVisibilityTest(annotation, x, y, pageMatrix);
38 }
39}

For fun, let's also override the control handle's draw function to make them look like triangles as well.

JavaScript

1class TriangleControlHandle extends Annotations.ControlHandle {
2 // ...
3 draw(ctx, annotation, selectionBox, zoom) {
4 const dim = this.getDimensions(annotation, selectionBox, zoom);
5 ctx.fillStyle = '#FFFFFF';
6 ctx.beginPath();
7 ctx.moveTo(dim.x1 + (dim.getWidth() / 2), dim.y1);
8 ctx.lineTo(dim.x1 + dim.getWidth(), dim.y1 + dim.getHeight());
9 ctx.lineTo(dim.x1, dim.y1 + dim.getHeight());
10 ctx.closePath();
11 ctx.stroke();
12 ctx.fill();
13 }
14}

If everything went well you should have triangle annotations that look something like this:

Apryse Docs Image

Saving the custom annotation

As mentioned early on, the CustomAnnotation class does handle saving our custom type as a stamp and automatically reloads the stamp as the custom type if it is registered. However, it will only preserve our type and our custom vertices property needs to be persisted as well.

If you download the document now and open it in another viewer, you will see the stamp of your custom annotation. If you tried to load this document or import the annotation through XFDF, you would notice that it isn't able to be reloaded. This is because WebViewer doesn't know that it needs to save the vertices array. We also need to save the vertices into the XFDF but we also run into another issue: vertices is not in the specification or part of the stamp.

Thus, we will need to save this into the annotation's custom data. To do this we can override the serialize and deserialize functions which are called when the annotation should be saved or loaded respectively.

1class TriangleAnnotation extends Annotations.CustomAnnotation {
2 // ...
3 serialize(element, pageMatrix) {
4 // save our custom property into the custom data
5 this.setCustomData('vertices', this.vertices);
6 // perform regular serialization on other properties
7 const el = super.serialize(element, pageMatrix);
8 return el;
9 }
10 deserialize(element, pageMatrix) {
11 // perform regular deserialization for other properties
12 super.deserialize(element, pageMatrix);
13 // read our custom property out from custom data
14 const storedVertices = this.getCustomData('vertices');
15 // set the property after initializing the data as points
16 this.vertices = storedVertices.map(v => new Core.Math.Point(v.x, v.y));
17 }
18}

After making this change you should be able to export XFDF and import the string back. You should also be able to download the document and reload it with your exact annotation still there. Viewing this annotation in another viewer will show the annotation as a stamp. Changes to the stamp will like not affect your custom annotation after loading it back in WebViewer

Stamp image settings (optional)

Now that you can save and load your custom annotation, you might have noticed if you open this custom annotation in another viewer, the triangle edges are cut off and it may look lower res. This is because the edges of the triangle are rendered past the bounds of the annotation and the image has been rasterized.

Apryse Docs Image

There are two static properties you can tweak to adjust this: OutputImagePadding and QualityScale.

JavaScript

1TriangleAnnotation.OutputImagePadding = 25; // adds 25 pixels all around
2TriangleAnnotation.QualityScale = 2; // doubles the resolution at the cost of memory

Please note that adding too much padding may scale down the perceived image. These options will not affect your WebViewer as the custom logic is available there.

Apryse Docs Image

Using Serialized Data

Using the annotation's custom data is useful for storing custom data. With CustomAnnotation, there is a SerializedData property that will automatically save the data attached to it. It is better to use this for primitive values rather than for complex objects.

For example, it would be better to store the number of vertices on this rather than the vertices since the vertices need to be transformed back into Point. It is still not impossible but carries some limitations.

JavaScript

1class TriangleAnnotation extends Annotations.CustomAnnotation {
2 // custom property
3 get CustomID() {
4 // attempt to get a customId value from the map
5 return this.SerializedData.customId;
6 }
7 set CustomID(id) {
8 // set a customId value from the map
9 this.SerializedData.customId = id;
10 }
11}

Saving custom XFDF (optional)

There may be some cases where you would prefer the XFDF to reflect the actual type of the custom annotation and not a stamp. For example, if you are only saving the XFDF of the annotations as opposed to the document. In this case, you can switch the static SerializationType property on the CustomAnnotation class from STAMP to CUSTOM. Please note that this will affect annotations of the same type and the custom XFDF will be discarded when merging with the document. If you are downloading the document, be sure to switch it back to stamp temporarily.

JavaScript

1TriangleAnnotation.SerializationType = Annotations.CustomAnnotation.SerializationTypes.CUSTOM; // use custom XFDF

Instead of a stamp in the XFDF:

XML

1<stamp page="0" rect="131.96,227.76999999999998,294.27,407.23" color="#000000" flags="print" name="bb8ac8fa-ff92-08ff-c2e5-90dbaeb9edde" title="Guest" subject="Triangle" date="D:20210319141059-07'00'" creationdate="D:20210319140524-07'00'">
2 <trn-custom-data bytes="..."/>
3 <imagedata>data:image/png;base64,...</imagedata>
4</stamp>

Your output XFDF should then look like this:

XML

1<triangle page="0" rect="131.96,227.76999999999998,294.27,407.23" color="#000000" flags="print" name="bb8ac8fa-ff92-08ff-c2e5-90dbaeb9edde" title="Guest" subject="Triangle" date="D:20210319141059-07'00'" creationdate="D:20210319140524-07'00'">
2 <trn-custom-data bytes="..."/>
3</triangle>

If you feel you want to add the custom properties to the XFDF (instead of custom data), feel free to include the following in your serialize and deserialize functions:

JavaScript

1class TriangleAnnotation extends Annotations.CustomAnnotation {
2 serialize(element, pageMatrix) {
3 const el = super.serialize(element, pageMatrix);
4 // create an attribute to save the vertices list
5 el.setAttribute('vertices', Annotations.XfdfUtils.serializePointArray(this.vertices, pageMatrix));
6 return el;
7 }
8 deserialize(element, pageMatrix) {
9 super.deserialize(element, pageMatrix);
10 // read it back as points from the attribute
11 this.vertices = Annotations.XfdfUtils.deserializePointArray(element.getAttribute('vertices'), pageMatrix);
12 }
13}

Did you find this helpful?

Trial setup questions?

Ask experts on Discord

Need other help?

Contact Support

Pricing or product questions?

Contact Sales