UNPKG

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
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;