WebP Cloud Services Blog

Implement real-time watermark preview using Fabric.js

Ā· Nova Kwok

Long ago, WebP Cloud supported watermark mode. When implementing the watermark feature, we referred to the designs of existing CDNs and integrated them into govips.

The frontend was designed like this:

I must say, this page is quite abstract. Users need to manually adjust the position and size of the watermark using sliders. Size is manageable, but positioning is challenging. Additionally, there is a usability issue with the preview image. Although the girl with pearl earrings is beautiful, users can hardly see what their watermark looks like in this situation šŸ¤Æ

Wouldnā€™t it be great if we could input text in a text box, like some screenshot annotation tools, and then simply drag the mouse to set the watermarkā€™s appearance?

We tried some mature solutions, such as image croppers, but found that their main functionality was image cropping, and adding text layers was not their primary feature. Moreover, integration was often cumbersome, especially since we were using Angular.

Since that was the case, we had to start from scratch. Fortunately, the requirement was not complex, so using fabric.js should be sufficient.

Creating and Initializing the Canvas

fabric.js operates entirely based on the canvas, so we need to add a canvas to the template file (html) in Angular:

<canvas id="c" width="300" height="300"></canvas>

Then, in the corresponding TypeScript fileā€™s ngAfterViewInit, we initialize our canvas:

import { fabric } from 'fabric';

....

ngAfterViewInit() {
    this.canvas = new fabric.Canvas('c');
}

Loading the Image

After discussions, we decided to use a solid-colored background image to replace the girl. Therefore, our canvas needs to load an image as the background. Unfortunately, fabric.Image.fromURL does not support the async/await syntax and is not worth wrapping in a Promise. Hence, we can only use the callback approach.

fabric.Image.fromURL(this._service.previewImageUrl, (oImg) => {
  oImg.scaleToWidth(300);
  this.canvas.add(oImg);
});

Our canvas is ready!

Adding the Watermark

The next step is to add the watermark to the canvas. We can use fabric.Text for this.

const text = new fabric.Text('hello');

// Inside the callback function
this.canvas.add(text);

Impressive JavaScript! We can freely adjust the position and size. Howeverā€¦ it seems a bit too free?

Restricting Canvas Behavior

We donā€™t want the background canvas to be movable, and we only want the watermark to be resizable without rotation or stretching.

Fortunately, Fabric provides interfaces to control the behavior of various components.

Restricting the Background

We lock the backgroundā€™s X and Y movement, and change the cursor to the default state.

fabric.Image.fromURL(this._service.previewImageUrl, (oImg) => {
  oImg.scaleToWidth(300);
  oImg.selectable = false;
  oImg.lockMovementX = true;
  oImg.lockMovementY = true;
  oImg.hoverCursor = 'default';
  this.canvas.add(oImg);
  this.canvas.add(text);
});

Restricting the Watermark

Now the watermark can only be resized (like Ant-Man).

text.setControlsVisibility({
  mt: false,
  mb: false,
  ml: false,
  mr: false,
  mtr: false,
});

Things Yet to be Doneā€¦

At this point, we still have the following tasks remaining:

  1. When users finish manipulating the watermark, calculate the correct parameter values when submitting the form. We need to know the watermarkā€™s color, text content, size (relative value), and offset (relative value).
  2. Further restrictions on the watermark: We donā€™t want the watermark to extend beyond the background. Although libvips supports this, it would result in black borders. We believe most users wouldnā€™t want their images to have mysterious black borders.

Restricting the Watermark within the Canvas

As shown in the following image, the watermark extends beyond the canvas. We want to prevent this. Users should be able to break the dimensional barrier by either zooming in on the watermark or dragging it.

This is where events come into play. We can listen for changes in the canvas to capture such abnormal situations.

According to the fabric documentation, object:moving and object:scaling are the events we need to listen for. Itā€™s important to note that these events are fired continuously during object movement, so we also need to debounce them; otherwise, it could result in significant performance loss.

Letā€™s write the code to check for this, considering both movement and scaling scenarios:

checkWatermarkBounds() {
  const watermark = this.canvas.getObjects('text')[0];

  // If the user scales the watermark, set it to the maximum size
  if (watermark.width && watermark.scaleX && watermark.width * watermark.scaleX > this.canvas.width) {
    watermark.scaleToWidth(this.canvas.width);
  }
  if (watermark.height && watermark.scaleY && watermark.height * watermark.scaleY > this.canvas.height) {
    watermark.scaleToHeight(this.canvas.height);
  }

  const imgBounds = {
    left: 0,
    top: 0,
    width: 300,
    height: 300,
  };
  const wmBounds = watermark.getBoundingRect();

  // Left boundary, the same logic applies to the other four directions
  if (wmBounds.left < imgBounds.left) {
    watermark.set('left', imgBounds.left);
  }
  // Other directions...
  watermark.setCoords();
}

Then, set up the event listeners:

import { debounce } from 'lodash-es';

const handleObjectTransform = debounce(() => {
      this.checkWatermarkBounds();
    }, 100);

this.canvas.on({
  'object:moving': handleObjectTransform,
  'object:scaling': handleObjectTransform,
});

I donā€™t know about you, but seeing all these parentheses makes me a little dizzy šŸ˜µā€šŸ’«

Calculating the Data

This step is quite straightforward. We can use fabricā€™s API to obtain the watermarkā€™s size and position, and then perform a division with the background. To be cautious, if the width or height is less than 1, we set it to 0 to avoid any unexpected issues with libvips.

updateWMPosition() {
  const text = this.canvas.getObjects('text')[0];
  const { offsetX, offsetY, width, height } = {
    offsetX: Math.max(text.left / this.canvas.width, 0),
    offsetY: Math.max(text.top / this.canvas.height, 0),
    width: (text.width * text.scaleX) / this.canvas.width,
    height: (text.height * text.scaleY) / this.canvas.height,
  };

  // Handle the logic for the data
}

Optimizationā€¦

If our goal is simply to make it easier to set the watermark, then we have achieved that. However, since:

WebP Cloud Services will strive to do what we believe is right and try to do our best within our resources and capabilities.

We still need to:

  1. Set the font and load the woff2 file for the web page.
  2. Support transparency.
  3. Support filling the watermark background with color.

Fabric supports all of these features!

Loading the Font

First, we need to find the woff2 file for the font we support and add it to the CSS:

@font-face {
  font-family: 'Roboto';
  src: url('https://static.webp.se/fonts/Roboto-Regular.woff2') format('woff2');
}

Load the font using font observer:

const font = new FontFaceObserver(family);
await font.load(null, 10000);

Usage:

const text = new fabric.Text("value text", {
  fill: 'ffffff',
  textBackgroundColor: 'ff0000',
  fontFamily: 'Roboto',
  left: 0.3 * this.canvas.width,
  top: 0.4 * this.canvas.height,
});

New Color Picker

We have developed a new color picker component that supports transparency and provides a set of default commonly used colors.

This component is tui-editor, hidden quite deepā€¦

By default, this component supports gradient mode, but we cannot use that. However, the configuration option is hardcoded.

Therefore, we can only use an onClick event and some magical code šŸ¤£

hideComponent() {
  setTimeout(() => {
    const element = document.querySelector('.t-select');
    if (element instanceof HTMLElement) {
      element.style.display = 'none';
    }
  }, 100);
}

Watermark Background and Transparency

Now we support the fill parameter, which means the watermark can have a background color of any kind, making it visible regardless of the appearance of the target image. So, how do we achieve this accurately in libvips?

Additionally, hex colors now support 8 digits, with the last 2 digits representing the transparency. For example, FF is completely opaque, and 00 is completely transparent. Text transparency is easy to handle with libvipsā€™ label method. However, handling background transparency is challenging because label does not support adding watermarks to images with 4 bands (including an alpha channel).

Watermark Background

Given the size, color, offset, and background color of the watermark, how can we accurately apply a watermark with a background color to the target image?

With a little thought, itā€™s not difficult to realize that if the watermark has a background, its size is actually 100%. This means we can create an intermediate layer of the corresponding size and background color, draw the watermark at 100% size on it, and then paste this layer onto the new image with the original offset.

// Watermark
label := vips.LabelParams{....}
// Create the image
overlay, _ := vips.Black(width, height)
_ = overlay.ToColorSpace(vips.InterpretationSRGB)

// If there is an alpha channel, remove it. Although it doesn't exist in this example.
_ = overlay.ExtractBand(0, 3)

// Fill the color
_ = overlay.DrawRect(vips.ColorRGBA{R: fillR, G: fillG, B: fillB, A: 255}, 0, 0, x, y, true)

// Calculate the overlay's offset
realOffsetX, readOffsetY := int(label.OffsetX.Value*float64(img.Width())), int(label.OffsetY.Value*float64(img.Height()))

// Set the label's offset to 0
label.OffsetY = vips.Scalar{Value: 0, Relative: true}
label.OffsetX = vips.Scalar{Value: 0, Relative: true}
// Fill the entire overlay
label.Width = vips.Scalar{Value: 1, Relative: true}
label.Height = vips.Scalar{Value: 1, Relative: true}
// Apply the watermark
_ = overlay.Label(&label)
// Composite the watermark onto the original image using BlendModeOver
err = img.Composite(overlay, vips.BlendModeOver, realOffsetX, readOffsetY)

Transparency

The fill color also supports transparency, but in the code overlay.DrawRect, we set the alpha channel value to 255. Why? Because label does not support adding watermarks to images with 4 bands.

ifthenelse: not one band or 4 bands

We took a shortcut and assumed a white background for a given RGBA color. From there, we calculated the RGB values:

func rgbaToRgb(r, g, b, a uint8) (uint8, uint8, uint8) {
	af := float64(a) / 255 // 0-1
	return uint8(float64(r)*af + (1-af)*255 + 0.5),
		uint8(float64(g)*af + (1-af)*255 + 0.5),
		uint8(float64(b)*af + (1-af)*255 + 0.5)
}

fillR, fillG, fillB := rgbaToRgb(.....)

The Final Lookā€¦

This is the frontend preview image:

This is the image generated by the backend:

What you see is what you get! šŸ¤© JavaScript is truly amazing!

Final Thoughts

Weā€™ve been pondering a question: if something can be done through dragging, does it render the manual option unnecessary?

In other words, if automatic transmission cars already exist, does manual transmission become obsolete? šŸ˜‚

More to Come

During these series of experiments and optimizations, we found that there is a lack of documentation on libvips, especially govips, on the internet, and even fewer Chinese-language resources.

We will strive to do what we believe is right and try to do our best within our resources and capabilities.

Therefore, after discussion, we decided to write a guide on using libvips. This guide will cover the basics of libvips, and the demonstration code will include implementations in three languages: Go, Python, and Javascript. Due to the complexity and challenges of the task, as well as our team members being full-time employees, it may take a long time to complete.

The initial version is currently in the planning phase and is scheduled to be launched within two weeks. We also welcome anyone interested to join this project at any stage or provide any form of assistance.


The WebP Cloud Services team is a small team of three individuals from Shanghai and Helsingborg. Since we are not funded and have no profit pressure, we remain committed to doing what we believe is right. We strive to do our best within the scope of our resources and capabilities. We also engage in various activities without affecting the services we provide to the public, and we continuously explore novel ideas in our products.

If you find this service interesting, feel free to log in to the WebP Cloud Dashboard to experience it. If youā€™re curious about other magical features it offers, take a look at our WebP Cloud Services Docs. We hope everyone enjoys using it!


Discuss on Hacker News