Implement real-time watermark preview using Fabric.js
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:
- 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).
- 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:
- Set the font and load the woff2 file for the web page.
- Support transparency.
- 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