@nanggo/social-preview
Version:
Generate beautiful social media preview images from any URL
323 lines (317 loc) • 12.8 kB
JavaScript
;
/**
* 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;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_DIMENSIONS = void 0;
exports.generateImage = generateImage;
exports.createBlankCanvas = createBlankCanvas;
exports.createFallbackImage = createFallbackImage;
const image_security_1 = require("../utils/image-security");
const types_1 = require("../types");
const utils_1 = require("../utils");
const sharp_cache_1 = require("../utils/sharp-cache");
const fonts_1 = require("../constants/fonts");
// Pre-load image security module at module level for performance
const imageSecurityPromise = Promise.resolve().then(() => __importStar(require('../utils/image-security')));
const metadata_extractor_1 = require("./metadata-extractor");
const validators_1 = require("../utils/validators");
/**
* 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 imageBuffer = await (0, metadata_extractor_1.fetchImage)(metadata.image);
baseImage = await processBackgroundImage(imageBuffer, width, height, template);
}
else {
// Create blank canvas with gradient background or transparent canvas based on template settings
if (template.imageProcessing?.requiresTransparentCanvas) {
baseImage = (0, validators_1.createTransparentCanvas)(width, height);
}
else {
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,
progressive: true,
mozjpeg: true
})
.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 with template-specific processing
*/
async function processBackgroundImage(imageBuffer, width, height, template) {
try {
// Use withSecureSharp for automatic pool management
const { withSecureSharp } = await imageSecurityPromise;
return await withSecureSharp(imageBuffer, async (image) => {
await image.metadata();
// Apply template-specific image processing settings
const imageProcessing = template.imageProcessing || {};
// Use secure resize function
let processedImage = (0, image_security_1.secureResize)(image, width, height, {
fit: 'cover',
position: 'center',
});
// Apply template-specific blur if specified
const blurRadius = imageProcessing.blur || template.effects?.blur?.radius || 2;
if (blurRadius > 0) {
processedImage = processedImage.blur(blurRadius);
}
// Apply template-specific brightness and saturation together for efficiency
const brightness = imageProcessing.brightness !== undefined ? imageProcessing.brightness : 0.7;
const saturation = imageProcessing.saturation;
// Apply modulation only once with all necessary changes
if (brightness !== 1 || saturation !== undefined) {
const modulateOptions = {};
if (brightness !== 1) {
modulateOptions.brightness = brightness;
}
if (saturation !== undefined) {
modulateOptions.saturation = saturation;
}
processedImage = processedImage.modulate(modulateOptions);
}
return processedImage;
});
}
catch (error) {
throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, 'Failed to process background image', error);
}
}
/**
* Create blank canvas with gradient background
* Uses caching for better performance with repeated requests
*/
async function createBlankCanvas(width, height, options) {
// Validate colors before using them in SVG
const backgroundColor = (0, validators_1.validateColor)(options.colors?.background || '#1a1a2e');
const accentColor = (0, validators_1.validateColor)(options.colors?.accent || '#16213e');
// Use cached canvas creation for performance
return (0, sharp_cache_1.createCachedCanvas)(width, height, {
colors: {
background: backgroundColor,
accent: accentColor
}
});
}
/**
* Generate text overlay SVG
*/
async function generateTextOverlay(metadata, template, width, height, options) {
const padding = template.layout.padding || 60;
const textColor = (0, validators_1.validateColor)(options.colors?.text || '#ffffff');
// 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 = (0, utils_1.wrapText)(metadata.title, maxTitleWidth, titleFontSize, template.typography.title.maxLines || 2);
const descLines = metadata.description
? (0, utils_1.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>
.title {
font-family: ${fonts_1.SYSTEM_FONT_STACK};
font-size: ${titleFontSize}px;
font-weight: 700;
fill: ${textColor};
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
}
.description {
font-family: ${fonts_1.SYSTEM_FONT_STACK};
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: ${fonts_1.SYSTEM_FONT_STACK};
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">
${(0, utils_1.escapeXml)(line)}
</text>
`)
.join('')}
<!-- Description -->
${descLines
.map((line, index) => `
<text x="${padding}" y="${descY + index * descFontSize * descLineHeight}" class="description">
${(0, utils_1.escapeXml)(line)}
</text>
`)
.join('')}
<!-- Site name / Domain -->
${metadata.siteName
? `
<text x="${padding}" y="${siteNameY}" class="siteName">
${(0, utils_1.escapeXml)(metadata.siteName.toUpperCase())}
</text>
`
: ''}
<!-- Decorative elements -->
<rect x="${padding}" y="${titleY - titleFontSize - 10}" width="60" height="4" fill="${(0, validators_1.validateColor)(options.colors?.accent || '#4a9eff')}" rx="2"/>
</svg>
`;
// Use cached SVG creation for better performance
const cachedSVG = await (0, sharp_cache_1.createCachedSVG)(overlaySvg);
return cachedSVG.toBuffer();
}
/**
* 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 'left':
case 'right':
case 'center':
default:
return (height - totalTextHeight) / 2 + fontSize;
}
}
/**
* Create fallback image when no metadata is available
*/
async function createFallbackImage(url, options = {}) {
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);
}
}