@cognima/banners
Version:
Biblioteca avançada para geração de banners dinâmicos para diversas plataformas e aplicações
461 lines (399 loc) • 16.8 kB
JavaScript
;
/**
* Módulo de Utilitários para @cognima/banners
*
* Este módulo contém funções auxiliares para manipulação de imagens, fontes,
* desenho em canvas e formatação de dados utilizados pelos geradores de banners.
*
* @author Cognima Team (melhorado)
* @version 2.0.0
*/
const pureimage = require("pureimage");
const fs = require("fs");
const path = require("path");
const axios = require("axios");
const { Readable } = require("stream");
// --- Constantes para Padronização ---
const DEFAULT_FONT_FAMILY = "Poppins";
const DEFAULT_FONT_DIR = path.join(__dirname, "/assets/fonts/Poppins");
const FONT_WEIGHTS = {
bold: "Poppins-Bold.ttf",
medium: "Poppins-Medium.ttf",
regular: "Poppins-Regular.ttf",
};
// --- Validação de Cores ---
/**
* Verifica se uma string é uma cor hexadecimal válida
* @param {string} color - Cor em formato hexadecimal (#RRGGBB ou #RGB)
* @returns {boolean} - Verdadeiro se for uma cor hexadecimal válida
*/
function isValidHexColor(color) {
return /^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(color);
}
// --- Registro de Fontes ---
let fontsRegistered = false;
const registeredFontNames = new Set(); // Controla nomes de fontes registradas com sucesso
/**
* Registra as fontes padrão do sistema
* @returns {Promise<string>} - Nome da família de fonte registrada
*/
async function registerDefaultFonts() {
if (fontsRegistered) {
return DEFAULT_FONT_FAMILY;
}
try {
const fontPromises = [];
// Registra cada peso de fonte
for (const weight in FONT_WEIGHTS) {
const fontPath = path.join(DEFAULT_FONT_DIR, FONT_WEIGHTS[weight]);
if (fs.existsSync(fontPath)) {
const fontName = `${DEFAULT_FONT_FAMILY}-${weight.charAt(0).toUpperCase() + weight.slice(1)}`;
const font = pureimage.registerFont(fontPath, fontName);
fontPromises.push(
font.load().then(() => {
registeredFontNames.add(fontName);
}).catch(err => {
console.error(`Falha ao carregar fonte ${fontName}:`, err);
throw err;
})
);
} else {
console.warn(`Arquivo de fonte padrão não encontrado: ${fontPath}`);
}
}
if (fontPromises.length > 0) {
await Promise.all(fontPromises);
if (registeredFontNames.size === Object.keys(FONT_WEIGHTS).length) {
fontsRegistered = true;
return DEFAULT_FONT_FAMILY;
} else {
throw new Error(`Apenas ${registeredFontNames.size}/${Object.keys(FONT_WEIGHTS).length} fontes padrão foram registradas com sucesso.`);
}
} else {
throw new Error("Nenhum arquivo de fonte padrão encontrado no diretório esperado.");
}
} catch (err) {
console.error("Erro ao registrar fontes padrão:", err);
throw new Error("Falha ao carregar fontes padrão. Certifique-se de que os arquivos da fonte Poppins (Bold, Medium, Regular) estão em assets/fonts/Poppins.");
}
}
/**
* Registra uma fonte personalizada se necessário
* @param {Object} fontConfig - Configuração da fonte (nome e caminho)
* @returns {Promise<string>} - Nome da família de fonte registrada
*/
async function registerFontIfNeeded(fontConfig) {
if (!fontConfig || !fontConfig.path || !fontConfig.name) {
// Usa fontes padrão se nenhuma fonte personalizada for fornecida ou a configuração estiver incompleta
return await registerDefaultFonts();
}
try {
if (fs.existsSync(fontConfig.path)) {
const font = pureimage.registerFont(fontConfig.path, fontConfig.name);
await font.load();
registeredFontNames.add(fontConfig.name);
return fontConfig.name; // Retorna o nome da fonte personalizada registrada
} else {
console.warn(`Arquivo de fonte personalizada não encontrado: ${fontConfig.path}. Usando fontes padrão.`);
return await registerDefaultFonts();
}
} catch (err) {
console.error(`Erro ao registrar fonte personalizada ${fontConfig.name}:`, err);
console.warn("Usando fontes padrão como alternativa.");
return await registerDefaultFonts();
}
}
// --- Carregamento de Imagens ---
/**
* Carrega uma imagem a partir de várias fontes possíveis (URL, Buffer, caminho local)
* @param {string|Buffer|Object} source - Fonte da imagem (URL, Buffer ou caminho local)
* @returns {Promise<Object>} - Objeto bitmap da imagem carregada
*/
async function loadImageWithAxios(source) {
if (!source) throw new Error("A fonte da imagem não pode ser nula ou vazia.");
// Carrega imagem a partir de URL
if (typeof source === "string" && source.startsWith("http")) {
try {
const response = await axios.get(source, { responseType: "arraybuffer" });
const buffer = Buffer.from(response.data, "binary");
const contentType = response.headers["content-type"];
// Tenta decodificar com base no tipo de conteúdo
if (contentType && contentType.includes("png")) {
return await pureimage.decodePNGFromStream(Readable.from(buffer));
} else if (contentType && (contentType.includes("jpeg") || contentType.includes("jpg"))) {
return await pureimage.decodeJPEGFromStream(Readable.from(buffer));
} else {
// Tenta decodificar, priorizando PNG e depois JPEG
try {
return await pureimage.decodePNGFromStream(Readable.from(buffer));
} catch (e) { /* ignora */ }
try {
return await pureimage.decodeJPEGFromStream(Readable.from(buffer));
} catch (e) { /* ignora */ }
throw new Error(`Tipo de imagem não suportado '${contentType}' da URL: ${source}`);
}
} catch (error) {
console.error(`Erro ao carregar imagem da URL ${source}:`, error.message);
throw new Error(`Falha ao carregar imagem da URL: ${source}. Verifique se a URL está correta e o formato da imagem é PNG ou JPEG.`);
}
}
// Carrega imagem a partir de Buffer
else if (Buffer.isBuffer(source)) {
try {
return await pureimage.decodePNGFromStream(Readable.from(source));
} catch (e) { /* ignora */ }
try {
return await pureimage.decodeJPEGFromStream(Readable.from(source));
} catch (e) { /* ignora */ }
throw new Error("Falha ao decodificar imagem do buffer. Certifique-se de que é um buffer PNG ou JPEG válido.");
}
// Carrega imagem a partir de caminho local
else if (typeof source === "string") {
try {
const stream = fs.createReadStream(source);
if (source.toLowerCase().endsWith(".png")) {
return await pureimage.decodePNGFromStream(stream);
}
if (source.toLowerCase().endsWith(".jpg") || source.toLowerCase().endsWith(".jpeg")) {
return await pureimage.decodeJPEGFromStream(stream);
}
throw new Error("Tipo de arquivo de imagem local não suportado. Apenas PNG e JPEG são suportados.");
} catch (error) {
console.error(`Erro ao carregar imagem do caminho ${source}:`, error.message);
throw new Error(`Falha ao carregar imagem do caminho: ${source}. Verifique se o caminho está correto e o arquivo é PNG ou JPEG.`);
}
}
// Já é um bitmap
else if (source && source.width && source.height && source.data) {
return source;
}
throw new Error("Fonte de imagem inválida fornecida. Deve ser uma URL (http/https), Buffer, caminho local ou um bitmap pureimage.");
}
// --- Codificação de Canvas ---
/**
* Codifica um canvas para um buffer PNG
* @param {Object} canvas - Objeto canvas do pureimage
* @returns {Promise<Buffer>} - Buffer contendo a imagem PNG
*/
async function encodeToBuffer(canvas) {
const writableStream = new (require("stream").Writable)({
write(chunk, encoding, callback) {
if (!this.data) this.data = [];
this.data.push(chunk);
callback();
},
});
await pureimage.encodePNGToStream(canvas, writableStream);
if (!writableStream.data) throw new Error("Falha ao codificar imagem para buffer.");
return Buffer.concat(writableStream.data);
}
// --- Auxiliares de Desenho ---
/**
* Desenha um retângulo com cantos arredondados
* @param {Object} ctx - Contexto de renderização 2D
* @param {number} x - Posição X do retângulo
* @param {number} y - Posição Y do retângulo
* @param {number} width - Largura do retângulo
* @param {number} height - Altura do retângulo
* @param {number|Object} radius - Raio dos cantos (número ou objeto com raios específicos)
* @param {boolean} fill - Se deve preencher o retângulo
* @param {boolean} stroke - Se deve desenhar o contorno do retângulo
*/
function roundRect(ctx, x, y, width, height, radius, fill, stroke) {
// Normaliza o raio para um objeto com todos os cantos
if (typeof radius === "number") {
radius = { tl: radius, tr: radius, br: radius, bl: radius };
} else {
radius = { ...{ tl: 0, tr: 0, br: 0, bl: 0 }, ...radius };
}
// Desenha o caminho do retângulo arredondado
ctx.beginPath();
ctx.moveTo(x + radius.tl, y);
ctx.lineTo(x + width - radius.tr, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr);
ctx.lineTo(x + width, y + height - radius.br);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height);
ctx.lineTo(x + radius.bl, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl);
ctx.lineTo(x, y + radius.tl);
ctx.quadraticCurveTo(x, y, x + radius.tl, y);
ctx.closePath();
// Aplica preenchimento e/ou contorno conforme solicitado
if (fill) {
ctx.fill();
}
if (stroke) {
ctx.stroke();
}
}
/**
* Quebra texto em múltiplas linhas para caber em uma largura máxima
* @param {Object} context - Contexto de renderização 2D
* @param {string} text - Texto a ser quebrado
* @param {number} x - Posição X inicial do texto
* @param {number} y - Posição Y inicial do texto
* @param {number} maxWidth - Largura máxima para o texto
* @param {number} lineHeight - Altura da linha
* @param {string} baseFontName - Nome base da fonte
* @returns {number} - Posição Y final após desenhar o texto
*/
function wrapText(context, text, x, y, maxWidth, lineHeight, baseFontName) {
const words = text.split(" ");
let line = "";
let currentY = y;
// Extrai informações da fonte atual
const initialFont = context.font || `16px ${baseFontName}-Regular`;
const fontMatch = initialFont.match(/\d+px/);
const fontSize = fontMatch ? fontMatch[0] : "16px";
const fontWeight = initialFont.includes("Bold") ? "Bold" : initialFont.includes("Medium") ? "Medium" : "Regular";
const fontName = `${baseFontName}-${fontWeight}`;
// Verifica se a fonte está registrada
if (!registeredFontNames.has(fontName)) {
context.font = `${fontSize} ${baseFontName}-Regular`;
} else {
context.font = `${fontSize} ${fontName}`;
}
// Processa cada palavra e quebra em linhas
for (let n = 0; n < words.length; n++) {
const testLine = line + words[n] + " ";
context.font = `${fontSize} ${fontName}`;
const metrics = context.measureText(testLine);
const testWidth = metrics.width;
if (testWidth > maxWidth && n > 0) {
context.fillText(line.trim(), x, currentY);
line = words[n] + " ";
currentY += lineHeight;
} else {
line = testLine;
}
}
// Desenha a última linha
context.font = `${fontSize} ${fontName}`;
context.fillText(line.trim(), x, currentY);
return currentY + lineHeight; // Retorna a posição Y para a próxima linha
}
// --- Auxiliares de Formatação ---
/**
* Formata um tempo em milissegundos para o formato MM:SS
* @param {number} ms - Tempo em milissegundos
* @returns {string} - Tempo formatado (MM:SS)
*/
function formatTime(ms) {
if (typeof ms !== "number" || isNaN(ms) || ms < 0) return "0:00";
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
}
/**
* Formata um número para exibição amigável (com K, M para milhares e milhões)
* @param {number} num - Número a ser formatado
* @returns {string} - Número formatado
*/
function formatNumber(num) {
if (typeof num !== "number" || isNaN(num)) return "0";
// Formatação simples K/M para números grandes
if (num >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, "") + "K";
return num.toString();
}
/**
* Aplica um efeito de sombra de texto padronizado
* @param {Object} ctx - Contexto de renderização 2D
* @param {string} color - Cor da sombra (hexadecimal)
* @param {number} blur - Desfoque da sombra
* @param {number} offsetX - Deslocamento X da sombra
* @param {number} offsetY - Deslocamento Y da sombra
*/
function applyTextShadow(ctx, color = "#000000", blur = 3, offsetX = 1, offsetY = 1) {
ctx.shadowColor = color;
ctx.shadowBlur = blur;
ctx.shadowOffsetX = offsetX;
ctx.shadowOffsetY = offsetY;
}
/**
* Remove efeitos de sombra do contexto
* @param {Object} ctx - Contexto de renderização 2D
*/
function clearShadow(ctx) {
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
}
/**
* Aplica um efeito de gradiente linear ao contexto
* @param {Object} ctx - Contexto de renderização 2D
* @param {number} x - Posição X inicial do gradiente
* @param {number} y - Posição Y inicial do gradiente
* @param {number} width - Largura do gradiente
* @param {number} height - Altura do gradiente
* @param {string} startColor - Cor inicial do gradiente (hexadecimal)
* @param {string} endColor - Cor final do gradiente (hexadecimal)
* @param {string} direction - Direção do gradiente ('horizontal', 'vertical', 'diagonal')
* @returns {Object} - Objeto de gradiente criado
*/
function createLinearGradient(ctx, x, y, width, height, startColor, endColor, direction = 'vertical') {
let gradient;
switch (direction.toLowerCase()) {
case 'horizontal':
gradient = ctx.createLinearGradient(x, y, x + width, y);
break;
case 'diagonal':
gradient = ctx.createLinearGradient(x, y, x + width, y + height);
break;
case 'vertical':
default:
gradient = ctx.createLinearGradient(x, y, x, y + height);
break;
}
gradient.addColorStop(0, startColor);
gradient.addColorStop(1, endColor);
return gradient;
}
/**
* Converte uma cor hexadecimal para RGBA
* @param {string} hex - Cor em formato hexadecimal
* @param {number} alpha - Valor de transparência (0-1)
* @returns {string} - Cor em formato RGBA
*/
function hexToRgba(hex, alpha = 1) {
if (!isValidHexColor(hex)) {
return `rgba(0, 0, 0, ${alpha})`;
}
// Expande formato abreviado (#RGB para #RRGGBB)
let r, g, b;
if (hex.length === 4) {
r = parseInt(hex[1] + hex[1], 16);
g = parseInt(hex[2] + hex[2], 16);
b = parseInt(hex[3] + hex[3], 16);
} else {
r = parseInt(hex.slice(1, 3), 16);
g = parseInt(hex.slice(3, 5), 16);
b = parseInt(hex.slice(5, 7), 16);
}
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// --- Exportações ---
module.exports = {
// Funções de validação
isValidHexColor,
// Funções de gerenciamento de fontes
registerDefaultFonts,
registerFontIfNeeded,
// Funções de manipulação de imagens
loadImageWithAxios,
encodeToBuffer,
// Funções de desenho
roundRect,
wrapText,
applyTextShadow,
clearShadow,
createLinearGradient,
// Funções de formatação
formatTime,
formatNumber,
hexToRgba,
// Constantes
DEFAULT_FONT_FAMILY
};