UNPKG

@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
"use strict"; /** * 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 };