images-watermark
Version:
🚀 A powerful, fast, and user-friendly Node.js library for adding professional watermarks to images. Protect your content with text watermarks, logo overlays, or both! Built with Sharp for lightning-fast processing, featuring intelligent caching, flexible
344 lines (298 loc) • 13.3 kB
JavaScript
const fs = require('fs').promises;
const NodeCache = require('node-cache');
const sharp = require('sharp');
// Initialize cache with 24-hour TTL
const cache = new NodeCache({ stdTTL: 86400 });
/**
* Images Watermark - A powerful Node.js library for adding professional watermarks to images
* @author Tirth Gaudani
* @version 1.1.1
*/
class Watermark {
/**
* Normalize referrer URL by removing protocol and trailing slash
* @param {string} referrer - The referrer URL to normalize
* @returns {string} Normalized referrer
*/
static normalizeReferrer(referrer) {
if (!referrer) return '';
return referrer.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
/**
* Generate repeating text watermark as SVG
* @param {string} text - Text to use as watermark
* @param {number} width - Image width
* @param {number} height - Image height
* @param {string} textColor - Text color (default: 'black')
* @param {string} opacity - Text opacity (default: '0.3')
* @param {string} fontWeight - Font weight (default: '800')
* @param {string} textLineSpacing - Line spacing multiplier (default: '5')
* @param {string} fontFamily - Font family (default: 'Inter, Arial, sans-serif')
* @returns {string} SVG watermark data
*/
static generateRepeatingTextWatermark(
text,
width,
height,
textColor = 'black',
opacity = '0.3',
fontWeight = '800',
textLineSpacing = '5',
fontFamily = 'Inter, Arial, sans-serif'
) {
try {
// Create cache key for this specific watermark configuration
const cacheKey = `${text}-${width}-${height}-${textColor}-${opacity}-${fontWeight}-${textLineSpacing}-${fontFamily}`;
// Check cache first
const cachedSvgData = cache.get(cacheKey);
if (cachedSvgData) {
return cachedSvgData;
}
// Calculate watermark parameters
const fontSize = Math.min(width, height) * 0.025;
const lineSpacing = fontSize * parseInt(textLineSpacing);
const textWidth = text.length * fontSize * 0.55;
const repeatCount = Math.ceil(width / textWidth) + 1;
const textRepeat = Array(repeatCount).fill(text).join(' ');
// Generate text lines
const textLines = Array.from(
{ length: Math.ceil(height / lineSpacing) },
(_, index) =>
`<text x="0" y="${index * lineSpacing}" font-size="${fontSize}" fill="${textColor}" opacity="${opacity}" style="font-family:${fontFamily};" font-weight="${fontWeight}">${textRepeat}</text>`
).join('');
const svgData = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${textLines}</svg>`;
// Cache the result
cache.set(cacheKey, svgData);
return svgData;
} catch (error) {
console.error('Error generating repeating text watermark:', error);
throw new Error(`Failed to generate text watermark: ${error.message}`);
}
}
/**
* Validate input parameters for watermark operations
* @param {Object} options - Watermark options
* @param {string} options.imagePath - Path to the image
* @param {Array} options.allowedReferrers - Array of allowed referrer domains
* @param {Object} options.headers - Request headers
* @throws {Error} If validation fails
*/
static validateOptions(options) {
const { imagePath, allowedReferrers, headers } = options;
if (!imagePath || imagePath.trim() === '') {
throw new Error('Image path is required and cannot be empty.');
}
if (!Array.isArray(allowedReferrers) || allowedReferrers.length === 0) {
throw new Error('Please provide an array of allowed referrer domains for security control.');
}
if (!headers || typeof headers !== 'object') {
throw new Error('Request headers are required for access control.');
}
}
/**
* Check if watermark should be applied based on referrer and headers
* @param {Object} headers - Request headers
* @param {Array} allowedReferrers - Array of allowed referrer domains
* @returns {boolean} True if watermark should be applied (external access)
*/
static shouldApplyWatermark(headers, allowedReferrers) {
const referrer = headers.referer || '';
const normalizedReferrer = this.normalizeReferrer(referrer);
const isValidReferrer = allowedReferrers.some(r =>
this.normalizeReferrer(r) === normalizedReferrer
);
const isCookieSet = Boolean(headers.cookie);
const isDirectAccess = headers['user-agent'] !== 'undici' && !isValidReferrer && isCookieSet;
return isDirectAccess;
}
/**
* Process single image with watermark
* @param {Object} options - Watermark options
* @param {string} options.imagePath - Path to the image
* @param {string} [options.watermarkPath] - Path to watermark image
* @param {Array} options.allowedReferrers - Array of allowed referrer domains
* @param {Object} options.headers - Request headers
* @param {string} [options.textWatermark] - Text watermark
* @param {string} [options.appName] - App name for watermark
* @param {string} [options.textColor='black'] - Text color
* @param {string} [options.opacity='0.3'] - Text opacity
* @param {string} [options.fontWeight='800'] - Font weight
* @param {string} [options.textLineSpacing='5'] - Line spacing multiplier
* @param {string} [options.fontFamily='Inter, Arial, sans-serif'] - Font family
* @returns {Promise<Buffer|string>} Processed image buffer or original path
*/
static async singleImageWatermark(options) {
try {
this.validateOptions(options);
const {
imagePath,
watermarkPath = '',
allowedReferrers,
headers,
textWatermark = '',
appName = '',
textColor = 'black',
opacity = '0.3',
fontWeight = '800',
textLineSpacing = '5',
fontFamily = 'Inter, Arial, sans-serif',
} = options;
// Check access permissions
if (!this.shouldApplyWatermark(headers, allowedReferrers)) {
return imagePath;
}
const watermarkText = appName || textWatermark;
const hasWatermark = watermarkPath || watermarkText;
// If no watermark is specified, return original image
if (!hasWatermark) {
return await fs.readFile(imagePath);
}
// Process image with watermark
const baseImage = sharp(imagePath);
const { width: baseWidth, height: baseHeight } = await baseImage.metadata();
// Prepare watermark components
const compositeImages = [];
// Add text watermark if specified
if (watermarkText) {
const textSvg = this.generateRepeatingTextWatermark(
watermarkText,
baseWidth,
baseHeight,
textColor,
opacity,
fontWeight,
textLineSpacing,
fontFamily
);
compositeImages.push({ input: Buffer.from(textSvg), gravity: 'center' });
}
// Add image watermark if specified
if (watermarkPath) {
const resizedWatermark = await sharp(watermarkPath)
.resize({
width: Math.floor(baseWidth * 0.1),
height: Math.floor(baseHeight * 0.1),
fit: 'inside',
})
.toBuffer();
compositeImages.push({ input: resizedWatermark, gravity: 'southeast' });
}
// Apply watermarks and return processed image
return await baseImage
.composite(compositeImages)
.png()
.toBuffer();
} catch (error) {
console.error('Error in singleImageWatermark:', error);
throw error;
}
}
/**
* Process multiple images with watermark
* @param {Object} options - Watermark options
* @param {Array} options.imagePaths - Array of image paths
* @param {string} [options.watermarkPath] - Path to watermark image
* @param {Array} options.allowedReferrers - Array of allowed referrer domains
* @param {Object} options.headers - Request headers
* @param {string} [options.textWatermark] - Text watermark
* @param {string} [options.appName] - App name for watermark
* @param {string} [options.textColor='black'] - Text color
* @param {string} [options.opacity='0.3'] - Text opacity
* @param {string} [options.fontWeight='800'] - Font weight
* @param {string} [options.textLineSpacing='5'] - Line spacing multiplier
* @param {string} [options.fontFamily='Inter, Arial, sans-serif'] - Font family
* @returns {Promise<Array<Buffer|string>>} Array of processed images
*/
static async multiImageWatermark(options) {
try {
const { imagePaths = [], ...otherOptions } = options;
if (!Array.isArray(imagePaths) || imagePaths.length === 0) {
throw new Error('Please provide a non-empty array of image paths.');
}
// Validate other options
this.validateOptions({ ...otherOptions, imagePath: imagePaths[0] });
const {
allowedReferrers,
headers,
watermarkPath = '',
textWatermark = '',
appName = '',
textColor = 'black',
opacity = '0.3',
fontWeight = '800',
textLineSpacing = '5',
fontFamily = 'Inter, Arial, sans-serif',
} = otherOptions;
// Check access permissions
if (!this.shouldApplyWatermark(headers, allowedReferrers)) {
return imagePaths;
}
const watermarkText = appName || textWatermark;
const hasWatermark = watermarkPath || watermarkText;
// Process all images in parallel
const processedImages = await Promise.all(
imagePaths.map(async (imagePath) => {
if (!imagePath || imagePath.trim() === '') {
throw new Error(`Invalid image path: ${imagePath}`);
}
// If no watermark, return original image
if (!hasWatermark) {
return await fs.readFile(imagePath);
}
// Process image with watermark
const baseImage = sharp(imagePath);
const { width: baseWidth, height: baseHeight } = await baseImage.metadata();
const compositeImages = [];
// Add text watermark
if (watermarkText) {
const textSvg = this.generateRepeatingTextWatermark(
watermarkText,
baseWidth,
baseHeight,
textColor,
opacity,
fontWeight,
textLineSpacing,
fontFamily
);
compositeImages.push({ input: Buffer.from(textSvg), gravity: 'center' });
}
// Add image watermark
if (watermarkPath) {
const resizedWatermark = await sharp(watermarkPath)
.resize({
width: Math.floor(baseWidth * 0.1),
height: Math.floor(baseHeight * 0.1),
fit: 'inside',
})
.toBuffer();
compositeImages.push({ input: resizedWatermark, gravity: 'southeast' });
}
return await baseImage
.composite(compositeImages)
.png()
.toBuffer();
})
);
return processedImages;
} catch (error) {
console.error('Error in multiImageWatermark:', error);
throw error;
}
}
/**
* Clear the watermark cache
*/
static clearCache() {
cache.flushAll();
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
static getCacheStats() {
return cache.getStats();
}
}
// Export the Watermark class
module.exports = Watermark;