Canvas Image Watermark Implementation: From Positioning to Tiled Patterns
Canvas Image Watermark Implementation: From Positioning to Tiled Patterns#
Adding watermarks to images is a common requirement—copyright protection, brand visibility, you name it. Recently built an online watermark tool, and here’s how it works under the hood.
The Essence of Watermarks#
It boils down to layering semi-transparent text or graphics over an image. Core code:
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0) // Draw original image first
ctx.fillText('Watermark', x, y) // Then draw watermark
But when building an actual tool, the details make or break the experience.
Five Positioning Algorithms#
Where should the watermark go? Seems simple, but there are five scenarios:
function getWatermarkPosition(
canvas: HTMLCanvasElement,
textWidth: number,
fontSize: number,
position: string
) {
const padding = 20
switch (position) {
case 'top-left':
return { x: padding, y: fontSize + padding }
case 'top-right':
return { x: canvas.width - textWidth - padding, y: fontSize + padding }
case 'bottom-left':
return { x: padding, y: canvas.height - padding }
case 'bottom-right':
return { x: canvas.width - textWidth - padding, y: canvas.height - padding }
case 'center':
return {
x: (canvas.width - textWidth) / 2,
y: canvas.height / 2 + fontSize / 2
}
}
}
Gotcha 1: Y Coordinate Isn’t the Top of Text
fillText(x, y) uses y as the baseline position, not the top edge. So for a top-left watermark, the Y coordinate should be fontSize + padding, not just padding.
Gotcha 2: Text Width Needs Measuring
Chinese and English characters, different fonts—widths vary wildly. Can’t estimate:
ctx.font = `${fontSize}px sans-serif`
const metrics = ctx.measureText(text)
const textWidth = metrics.width
Only this gives accurate positioning.
Transparency Handling#
Watermarks need semi-transparency to avoid obscuring the original. Two approaches:
Approach 1: globalAlpha#
ctx.globalAlpha = 0.5 // 50% transparency
ctx.fillText('Watermark', x, y)
ctx.globalAlpha = 1 // Reset to avoid affecting subsequent draws
Approach 2: RGBA Color#
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)' // White, 50% transparent
ctx.fillText('Watermark', x, y)
Key differences:
| Approach | Use Case | Pros | Cons |
|---|---|---|---|
| globalAlpha | Mixed text and graphics | Unified control | Affects subsequent draws |
| rgba color | Single-color text | No state pollution | Only works for single color |
My tool uses globalAlpha because it handles future image watermarks better with unified control.
Performance Optimization for Real-Time Preview#
When users adjust parameters, they need instant feedback. But large images can cause lag with constant redrawing.
Solution: Scaled Preview
// Limit preview canvas size
const maxW = 800
const maxH = 600
let w = img.width
let h = img.height
if (w > maxW) {
h = h * maxW / w
w = maxW
}
if (h > maxH) {
w = w * maxH / h
h = maxH
}
canvas.width = w
canvas.height = h
ctx.drawImage(img, 0, 0, w, h)
Critical: Scale Font Size Too
const scaledFontSize = fontSize * (w / img.width)
ctx.font = `${scaledFontSize}px sans-serif`
The preview image is scaled down, so the font must scale too—otherwise the watermark appears disproportionately large.
When exporting, use original dimensions to maintain quality.
Advanced: Tiled Watermarks#
Single-point watermarks are easy to crop. Tiled patterns are safer. Implementation:
function drawTiledWatermark(
ctx: CanvasRenderingContext2D,
text: string,
canvasWidth: number,
canvasHeight: number,
gap: number = 100
) {
ctx.save()
ctx.globalAlpha = 0.3
ctx.font = '20px sans-serif'
ctx.fillStyle = '#ffffff'
// Rotate -15 degrees to prevent easy cropping
ctx.translate(canvasWidth / 2, canvasHeight / 2)
ctx.rotate((-15 * Math.PI) / 180)
ctx.translate(-canvasWidth / 2, -canvasHeight / 2)
const textWidth = ctx.measureText(text).width
for (let y = 0; y < canvasHeight + gap; y += gap) {
for (let x = 0; x < canvasWidth + textWidth + gap; x += textWidth + gap) {
ctx.fillText(text, x, y)
}
}
ctx.restore()
}
Key Points:
- Rotation Angle: -15° is common—good anti-cropping effect
- Spacing Calculation: X spacing = text width + gap, Y spacing = gap
- Boundary Handling:
y < canvasHeight + gapensures edges have watermarks too
Export Format Selection#
canvas.toDataURL() supports three formats:
canvas.toDataURL('image/png') // Lossless, supports transparency, larger files
canvas.toDataURL('image/jpeg', 0.9) // Lossy, no transparency, smaller files
canvas.toDataURL('image/webp', 0.9) // Modern format, smallest size
JPEG doesn’t support transparency—semi-transparent watermarks get a black background. That’s why watermark tools typically use PNG.
But PNG files are large. Give users a choice:
const format = originalFormat === 'image/jpeg' ? 'image/jpeg' : 'image/png'
const url = canvas.toDataURL(format, 0.9)
If the original is JPEG, export as JPEG (with opaque watermark); otherwise use PNG.
Complete Workflow#
function addWatermark(image: HTMLImageElement, options: WatermarkOptions) {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = image.width
canvas.height = image.height
// 1. Draw original image
ctx.drawImage(image, 0, 0)
// 2. Set watermark style
ctx.font = `${options.fontSize}px ${options.fontFamily}`
ctx.fillStyle = options.color
ctx.globalAlpha = options.opacity
// 3. Calculate position
const textWidth = ctx.measureText(options.text).width
const { x, y } = getWatermarkPosition(canvas, textWidth, options.fontSize, options.position)
// 4. Draw watermark
ctx.fillText(options.text, x, y)
// 5. Export
return canvas.toDataURL('image/png')
}
Live Tool#
Built an online tool based on these principles: Image Watermark Tool
Features:
- Five positioning modes
- Custom font size, color, opacity
- Real-time preview
- Drag-and-drop upload
The technical implementation isn’t complex, but nailing the user experience takes thought. Hope this helps!
Related Tools: Image Compress | Image Crop