UNPKG

@nanggo/social-preview

Version:

Generate beautiful social media preview images from any URL

344 lines (338 loc) 13.2 kB
"use strict"; /** * Image Generator Module * Handles image processing and generation using Sharp */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_DIMENSIONS = void 0; exports.generateImage = generateImage; exports.createFallbackImage = createFallbackImage; const sharp_1 = __importDefault(require("sharp")); const types_1 = require("../types"); /** * Default dimensions for social media preview images */ exports.DEFAULT_DIMENSIONS = { width: 1200, height: 630, }; /** * Generate image buffer from metadata and template */ async function generateImage(metadata, template, options = {}) { try { const width = options.width || exports.DEFAULT_DIMENSIONS.width; const height = options.height || exports.DEFAULT_DIMENSIONS.height; const quality = options.quality || 90; // Create base image or use existing image let baseImage; if (metadata.image) { // Use existing image as background const { fetchImage } = await Promise.resolve().then(() => __importStar(require('./metadata-extractor'))); const imageBuffer = await fetchImage(metadata.image); baseImage = await processBackgroundImage(imageBuffer, width, height); } else { // Create blank canvas with gradient background baseImage = await createBlankCanvas(width, height, options); } // Generate text overlay SVG const overlayBuffer = await generateTextOverlay(metadata, template, width, height, options); // Composite text overlay on base image const finalImage = await baseImage .composite([{ input: overlayBuffer, top: 0, left: 0, }]) .jpeg({ quality }) .toBuffer(); return finalImage; } catch (error) { if (error instanceof types_1.PreviewGeneratorError) { throw error; } throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, 'Failed to generate image', error); } } /** * Process background image to fit dimensions */ async function processBackgroundImage(imageBuffer, width, height) { try { const image = (0, sharp_1.default)(imageBuffer); const metadata = await image.metadata(); // Resize and crop to fit exact dimensions return image .resize(width, height, { fit: 'cover', position: 'center', }) .blur(2) // Slight blur for better text readability .modulate({ brightness: 0.7, // Darken slightly for better text contrast }); } catch (error) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, 'Failed to process background image', error); } } /** * Create blank canvas with gradient background */ async function createBlankCanvas(width, height, options) { const backgroundColor = options.colors?.background || '#1a1a2e'; const accentColor = options.colors?.accent || '#16213e'; // Create gradient SVG const gradientSvg = ` <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%"> <stop offset="0%" style="stop-color:${backgroundColor};stop-opacity:1" /> <stop offset="100%" style="stop-color:${accentColor};stop-opacity:1" /> </linearGradient> <pattern id="pattern" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse"> <circle cx="2" cy="2" r="1" fill="white" opacity="0.05"/> </pattern> </defs> <rect width="${width}" height="${height}" fill="url(#bgGradient)"/> <rect width="${width}" height="${height}" fill="url(#pattern)"/> </svg> `; return (0, sharp_1.default)(Buffer.from(gradientSvg)); } /** * Generate text overlay SVG */ async function generateTextOverlay(metadata, template, width, height, options) { const padding = template.layout.padding || 60; const textColor = options.colors?.text || '#ffffff'; const overlayColor = options.colors?.overlay || 'rgba(0, 0, 0, 0.4)'; // Calculate text dimensions const maxTitleWidth = width - (padding * 2); const maxDescWidth = width - (padding * 2); // Typography settings const titleFontSize = template.typography.title.fontSize || 48; const titleLineHeight = template.typography.title.lineHeight || 1.2; const descFontSize = template.typography.description?.fontSize || 24; const descLineHeight = template.typography.description?.lineHeight || 1.4; const siteNameFontSize = template.typography.siteName?.fontSize || 20; // Truncate and wrap text const titleLines = wrapText(metadata.title, maxTitleWidth, titleFontSize, template.typography.title.maxLines || 2); const descLines = metadata.description ? wrapText(metadata.description, maxDescWidth, descFontSize, template.typography.description?.maxLines || 2) : []; // Calculate positions const titleY = calculateTitlePosition(height, padding, titleLines.length, titleFontSize, titleLineHeight, template.layout.titlePosition); const descY = titleY + (titleLines.length * titleFontSize * titleLineHeight) + 20; const siteNameY = height - padding - 10; // Create overlay SVG const overlaySvg = ` <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <defs> <style> @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'); .title { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: ${titleFontSize}px; font-weight: 700; fill: ${textColor}; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); } .description { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: ${descFontSize}px; font-weight: 400; fill: ${textColor}; opacity: 0.9; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); } .siteName { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: ${siteNameFontSize}px; font-weight: 600; fill: ${textColor}; opacity: 0.8; } </style> <linearGradient id="overlayGradient" x1="0%" y1="0%" x2="0%" y2="100%"> <stop offset="0%" style="stop-color:rgba(0,0,0,0.6);stop-opacity:1" /> <stop offset="100%" style="stop-color:rgba(0,0,0,0.2);stop-opacity:1" /> </linearGradient> </defs> <!-- Semi-transparent overlay for better text readability --> ${metadata.image ? `<rect width="${width}" height="${height}" fill="url(#overlayGradient)"/>` : ''} <!-- Title --> ${titleLines.map((line, index) => ` <text x="${padding}" y="${titleY + (index * titleFontSize * titleLineHeight)}" class="title"> ${escapeXml(line)} </text> `).join('')} <!-- Description --> ${descLines.map((line, index) => ` <text x="${padding}" y="${descY + (index * descFontSize * descLineHeight)}" class="description"> ${escapeXml(line)} </text> `).join('')} <!-- Site name / Domain --> ${metadata.siteName ? ` <text x="${padding}" y="${siteNameY}" class="siteName"> ${escapeXml(metadata.siteName.toUpperCase())} </text> ` : ''} <!-- Decorative elements --> <rect x="${padding}" y="${titleY - titleFontSize - 10}" width="60" height="4" fill="${options.colors?.accent || '#4a9eff'}" rx="2"/> </svg> `; return Buffer.from(overlaySvg); } /** * Calculate title position based on layout configuration */ function calculateTitlePosition(height, padding, lineCount, fontSize, lineHeight, position) { const totalTextHeight = lineCount * fontSize * lineHeight; switch (position) { case 'top': return padding + fontSize; case 'bottom': return height - padding - totalTextHeight; case 'center': default: return (height - totalTextHeight) / 2 + fontSize; } } /** * Wrap text to fit within maximum width */ function wrapText(text, maxWidth, fontSize, maxLines) { // Approximate character width (this is a simplified approach) const avgCharWidth = fontSize * 0.6; const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth); const words = text.split(' '); const lines = []; let currentLine = ''; for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word; if (testLine.length <= maxCharsPerLine) { currentLine = testLine; } else { if (currentLine) { lines.push(currentLine); currentLine = word; } else { // Word is too long, truncate it lines.push(word.substring(0, maxCharsPerLine - 3) + '...'); currentLine = ''; } } if (lines.length >= maxLines - 1 && currentLine) { // We're at the last allowed line const remainingWords = words.slice(words.indexOf(word) + 1); if (remainingWords.length > 0) { // Add ellipsis if there's more text currentLine = currentLine.substring(0, maxCharsPerLine - 3) + '...'; } lines.push(currentLine); break; } } if (currentLine && lines.length < maxLines) { lines.push(currentLine); } return lines; } /** * Escape XML special characters */ function escapeXml(text) { return text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;'); } /** * Create fallback image when no metadata is available */ async function createFallbackImage(url, options = {}) { const width = options.width || exports.DEFAULT_DIMENSIONS.width; const height = options.height || exports.DEFAULT_DIMENSIONS.height; const quality = options.quality || 90; try { const urlObj = new URL(url); const domain = urlObj.hostname; // Create simple fallback metadata const fallbackMetadata = { title: options.fallback?.text || domain, description: `Visit ${domain} for more information`, url, domain, siteName: domain.replace('www.', ''), }; // Use a simple template for fallback const fallbackTemplate = { name: 'fallback', layout: { padding: 60, titlePosition: 'center', }, typography: { title: { fontSize: 42, fontWeight: '600', lineHeight: 1.3, maxLines: 2, }, description: { fontSize: 22, lineHeight: 1.4, maxLines: 1, }, }, }; return await generateImage(fallbackMetadata, fallbackTemplate, options); } catch (error) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, 'Failed to create fallback image', error); } }