@nanggo/social-preview
Version:
Generate beautiful social media preview images from any URL
297 lines (296 loc) • 10.3 kB
JavaScript
;
/**
* Common utility functions shared across the project
*/
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 __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.escapeXml = escapeXml;
exports.adjustBrightness = adjustBrightness;
exports.wrapText = wrapText;
exports.generateSvgGradient = generateSvgGradient;
exports.createSvgText = createSvgText;
exports.truncateText = truncateText;
// Re-export logger utilities
__exportStar(require("./logger"), exports);
// Re-export validation utilities
__exportStar(require("./validation"), exports);
/**
* Escape XML special characters for safe SVG text rendering
*/
function escapeXml(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Adjust color brightness by a percentage
* @param color - Color string (hex, rgb, rgba, hsl, hsla, or named color)
* @param percent - Brightness adjustment percentage (-100 to 100)
* @returns Adjusted color string (always returns hex format for consistency)
*/
function adjustBrightness(color, percent) {
// Convert various color formats to RGB values
const rgb = parseColor(color);
if (!rgb) {
// If parsing fails, return original color
return color;
}
// Apply brightness adjustment
const amt = Math.round(2.55 * percent);
const r = Math.max(0, Math.min(255, rgb.r + amt));
const g = Math.max(0, Math.min(255, rgb.g + amt));
const b = Math.max(0, Math.min(255, rgb.b + amt));
// Return as hex format
return rgbToHex(r, g, b);
}
/**
* Parse various color formats to RGB values
* @param color - Color string in various formats
* @returns RGB object or null if parsing fails
*/
function parseColor(color) {
const trimmedColor = color.trim().toLowerCase();
// Hex colors (#RGB, #RRGGBB, #RRGGBBAA)
if (trimmedColor.startsWith('#')) {
const hex = trimmedColor.slice(1);
if (hex.length === 3) {
// #RGB -> #RRGGBB
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
return { r, g, b };
}
else if (hex.length === 6) {
// #RRGGBB
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
else if (hex.length === 8) {
// #RRGGBBAA (ignore alpha)
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
}
// RGB/RGBA colors
const rgbMatch = trimmedColor.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+)?\s*\)/);
if (rgbMatch) {
return {
r: parseInt(rgbMatch[1], 10),
g: parseInt(rgbMatch[2], 10),
b: parseInt(rgbMatch[3], 10),
};
}
// HSL/HSLA colors - basic conversion
const hslMatch = trimmedColor.match(/hsla?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*[\d.]+)?\s*\)/);
if (hslMatch) {
const h = parseInt(hslMatch[1], 10) / 360;
const s = parseInt(hslMatch[2], 10) / 100;
const l = parseInt(hslMatch[3], 10) / 100;
return hslToRgb(h, s, l);
}
// Named colors - basic support for common ones
const namedColors = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
red: { r: 255, g: 0, b: 0 },
green: { r: 0, g: 128, b: 0 },
blue: { r: 0, g: 0, b: 255 },
yellow: { r: 255, g: 255, b: 0 },
cyan: { r: 0, g: 255, b: 255 },
magenta: { r: 255, g: 0, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
orange: { r: 255, g: 165, b: 0 },
purple: { r: 128, g: 0, b: 128 },
};
if (namedColors[trimmedColor]) {
return namedColors[trimmedColor];
}
return null;
}
/**
* Convert RGB values to hex string
*/
function rgbToHex(r, g, b) {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
/**
* Convert HSL to RGB
*/
function hslToRgb(h, s, l) {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
}
else {
const hue2rgb = (p, q, t) => {
if (t < 0)
t += 1;
if (t > 1)
t -= 1;
if (t < 1 / 6)
return p + (q - p) * 6 * t;
if (t < 1 / 2)
return q;
if (t < 2 / 3)
return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
};
}
/**
* Wrap text to fit within specified constraints
* @param text - Text to wrap
* @param maxWidth - Maximum width in pixels
* @param fontSize - Font size in pixels
* @param maxLines - Maximum number of lines
* @param fontFamily - Font family for width calculation (affects character width)
* @returns Array of text lines
*/
function wrapText(text, maxWidth, fontSize, maxLines, fontFamily = 'default') {
// Font-specific character width multipliers
const fontMultipliers = {
inter: 0.55, // Inter font is more condensed
default: 0.6, // Default system font
};
const avgCharWidth = fontSize * fontMultipliers[fontFamily];
const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
// Handle empty text
if (!text.trim()) {
return [];
}
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (let i = 0; i < words.length; i++) {
const word = words[i];
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 = '';
}
}
// Check if we've reached max lines
if (lines.length >= maxLines - 1 && currentLine) {
const remainingWords = words.slice(i + 1);
if (remainingWords.length > 0) {
// Add ellipsis if there's more text
const truncatedLine = currentLine.substring(0, maxCharsPerLine - 3) + '...';
lines.push(truncatedLine);
}
else {
lines.push(currentLine);
}
break;
}
}
if (currentLine && lines.length < maxLines) {
lines.push(currentLine);
}
// Ensure we have at least one line for very short text that needs wrapping
if (lines.length === 0 && currentLine) {
lines.push(currentLine);
}
return lines;
}
/**
* Generate SVG gradient definition
* @param id - Gradient ID for SVG reference
* @param colors - Array of color stops
* @param direction - Gradient direction (angle in degrees or keywords)
* @returns SVG gradient definition string
*/
function generateSvgGradient(id, colors, direction = '0deg') {
// Convert direction to SVG coordinates
let x1 = '0%', y1 = '0%', x2 = '100%', y2 = '0%';
if (typeof direction === 'number' || direction.endsWith('deg')) {
const angle = typeof direction === 'number' ? direction : parseInt(direction);
const rad = (angle * Math.PI) / 180;
x1 = '50%';
y1 = '50%';
x2 = `${50 + 50 * Math.cos(rad)}%`;
y2 = `${50 + 50 * Math.sin(rad)}%`;
}
else if (direction === 'vertical' || direction === '180deg') {
x1 = '0%';
y1 = '0%';
x2 = '0%';
y2 = '100%';
}
const stops = colors
.map(({ offset, color, opacity = 1 }) => `<stop offset="${offset}" style="stop-color:${color};stop-opacity:${opacity}" />`)
.join('\n ');
return `
<linearGradient id="${id}" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
${stops}
</linearGradient>
`;
}
/**
* Create SVG text element with proper positioning and styling
* @param content - Text content
* @param x - X coordinate
* @param y - Y coordinate
* @param className - CSS class name
* @param attributes - Additional SVG attributes
* @returns SVG text element string
*/
function createSvgText(content, x, y, className, attributes = {}) {
const escapedContent = escapeXml(content);
const classAttr = className ? `class="${className}"` : '';
const additionalAttrs = Object.entries(attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
return `<text x="${x}" y="${y}" ${classAttr} ${additionalAttrs}>${escapedContent}</text>`;
}
/**
* Truncate text to specified length with ellipsis
* @param text - Text to truncate
* @param maxLength - Maximum length
* @param ellipsis - Ellipsis string (default: '...')
* @returns Truncated text
*/
function truncateText(text, maxLength, ellipsis = '...') {
if (text.length <= maxLength)
return text;
return text.substring(0, maxLength - ellipsis.length) + ellipsis;
}