UNPKG

@cognima/banners

Version:

Biblioteca avançada para geração de banners dinâmicos para diversas plataformas e aplicações

756 lines (649 loc) 24.9 kB
"use strict"; /** * Módulo de Banner de Perfil Moderno * * Este módulo gera banners de perfil com design moderno, utilizando elementos * visuais contemporâneos como glassmorphism, gradientes e efeitos sutis. * * @author Cognima Team (melhorado) * @version 2.0.0 */ Object.defineProperty(exports, "__esModule", { value: true }); const pureimage = require("pureimage"); const path = require("path"); const { loadImageWithAxios, encodeToBuffer, roundRect, wrapText, registerFontIfNeeded, isValidHexColor, DEFAULT_FONT_FAMILY, applyTextShadow, clearShadow, createLinearGradient, hexToRgba } = require("../utils"); const { DEFAULT_COLORS, LAYOUT, DEFAULT_DIMENSIONS, USER_STATUS } = require("./constants"); const { applyGlassmorphism, applyNeomorphism, applyMultiColorGradient, applyGlow } = require("./effects"); /** * @class ModernProfile * @classdesc Gera um banner de perfil com design moderno e elementos visuais contemporâneos. * @example const profile = new ModernProfile() * .setName("Nome Completo") * .setTitle("Desenvolvedor Frontend") * .setBio("Especialista em UI/UX e desenvolvimento web moderno") * .setAvatar("avatar.png") * .setBackground("image", "background.jpg") * .addStat("Projetos", "125") * .addStat("Seguidores", "3.2K") * .addStat("Avaliação", "4.9") * .setTheme("glassmorphism") * .build(); */ module.exports = class ModernProfile { constructor(options) { // Dados Principais this.name = "Nome Completo"; this.title = null; this.bio = null; this.avatar = null; this.background = { type: "color", value: DEFAULT_COLORS.gradient.purple.start }; this.stats = []; this.badges = []; this.links = []; this.status = "online"; // Personalização Visual this.font = { name: options?.font?.name ?? DEFAULT_FONT_FAMILY, path: options?.font?.path }; this.theme = "glassmorphism"; // glassmorphism, neomorphism, gradient, minimal this.primaryColor = "#FFFFFF"; this.secondaryColor = "rgba(255, 255, 255, 0.7)"; this.accentColor = DEFAULT_COLORS.accent.purple; this.useTextShadow = true; this.useGradientBackground = true; this.gradientColors = [DEFAULT_COLORS.gradient.purple.start, DEFAULT_COLORS.gradient.purple.end]; this.gradientDirection = "diagonal"; // Configurações de Layout this.cardWidth = DEFAULT_DIMENSIONS.profile.width; this.cardHeight = 400; this.cornerRadius = LAYOUT.cornerRadius.large; this.avatarSize = 120; this.avatarBorderWidth = 4; } // --- Setters para Dados Principais --- /** * Define o nome completo * @param {string} text - Nome completo * @returns {ModernProfile} - Instância atual para encadeamento */ setName(text) { if (!text || typeof text !== "string") throw new Error("O nome completo deve ser uma string não vazia."); this.name = text; return this; } /** * Define o título/cargo * @param {string} text - Título ou cargo * @returns {ModernProfile} - Instância atual para encadeamento */ setTitle(text) { if (!text || typeof text !== "string") throw new Error("O título deve ser uma string não vazia."); this.title = text; return this; } /** * Define a bio * @param {string} text - Texto da bio * @returns {ModernProfile} - Instância atual para encadeamento */ setBio(text) { if (!text || typeof text !== "string") throw new Error("A bio deve ser uma string não vazia."); this.bio = text; return this; } /** * Define o avatar * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar * @returns {ModernProfile} - Instância atual para encadeamento */ setAvatar(image) { if (!image) throw new Error("A fonte da imagem do avatar não pode estar vazia."); this.avatar = image; return this; } /** * Define o plano de fundo * @param {string} type - Tipo de plano de fundo ('color', 'image' ou 'gradient') * @param {string|Array} value - Valor do plano de fundo (cor hexadecimal, URL/caminho da imagem ou array de cores para gradiente) * @param {string} direction - Direção do gradiente (apenas para tipo 'gradient') * @returns {ModernProfile} - Instância atual para encadeamento */ setBackground(type, value, direction = "diagonal") { const types = ["color", "image", "gradient"]; if (!type || !types.includes(type.toLowerCase())) { throw new Error("O tipo de plano de fundo deve ser 'color', 'image' ou 'gradient'."); } if (!value) throw new Error("O valor do plano de fundo não pode estar vazio."); if (type.toLowerCase() === "color" && !isValidHexColor(value)) { throw new Error("Cor de plano de fundo inválida. Use o formato hexadecimal."); } if (type.toLowerCase() === "gradient") { if (!Array.isArray(value) || value.length < 2) { throw new Error("Para gradiente, forneça um array com pelo menos duas cores hexadecimais."); } for (const color of value) { if (!isValidHexColor(color)) { throw new Error("Todas as cores do gradiente devem estar no formato hexadecimal."); } } this.useGradientBackground = true; this.gradientColors = value; const validDirections = ["horizontal", "vertical", "diagonal", "radial"]; if (direction && validDirections.includes(direction.toLowerCase())) { this.gradientDirection = direction.toLowerCase(); } this.background = { type: "gradient", value }; } else { this.useGradientBackground = false; this.background = { type: type.toLowerCase(), value }; } return this; } /** * Adiciona uma estatística ao perfil * @param {string} label - Rótulo da estatística * @param {string|number} value - Valor da estatística * @returns {ModernProfile} - Instância atual para encadeamento */ addStat(label, value) { if (!label || typeof label !== "string") throw new Error("O rótulo da estatística deve ser uma string não vazia."); if (value === undefined || value === null) throw new Error("O valor da estatística não pode estar vazio."); this.stats.push({ label, value: value.toString() }); return this; } /** * Adiciona um distintivo ao perfil * @param {Object} badge - Objeto do distintivo com url e descrição * @returns {ModernProfile} - Instância atual para encadeamento */ addBadge(badge) { if (!badge || typeof badge !== "object" || !badge.url) { throw new Error("O distintivo deve ser um objeto com pelo menos uma propriedade \"url\"."); } this.badges.push({ url: badge.url, description: badge.description || "Distintivo" }); return this; } /** * Adiciona um link ao perfil * @param {string} label - Rótulo do link * @param {string} url - URL do link * @param {string} icon - Ícone do link (opcional) * @returns {ModernProfile} - Instância atual para encadeamento */ addLink(label, url, icon = null) { if (!label || typeof label !== "string") throw new Error("O rótulo do link deve ser uma string não vazia."); if (!url || typeof url !== "string") throw new Error("A URL do link deve ser uma string não vazia."); this.links.push({ label, url, icon }); return this; } /** * Define o status do usuário * @param {string} status - Status do usuário ('online', 'idle', 'dnd', 'streaming', 'offline') * @returns {ModernProfile} - Instância atual para encadeamento */ setStatus(status) { if (!status || !USER_STATUS[status.toLowerCase()]) { throw new Error(`Status inválido. Use um dos seguintes: ${Object.keys(USER_STATUS).join(", ")}`); } this.status = status.toLowerCase(); return this; } // --- Setters para Personalização Visual --- /** * Define o tema * @param {string} theme - Tema ('glassmorphism', 'neomorphism', 'gradient', 'minimal') * @returns {ModernProfile} - Instância atual para encadeamento */ setTheme(theme) { const validThemes = ["glassmorphism", "neomorphism", "gradient", "minimal"]; if (!theme || !validThemes.includes(theme.toLowerCase())) { throw new Error(`Tema inválido. Use um dos seguintes: ${validThemes.join(", ")}`); } this.theme = theme.toLowerCase(); return this; } /** * Define a cor primária * @param {string} color - Cor hexadecimal * @returns {ModernProfile} - Instância atual para encadeamento */ setPrimaryColor(color) { if (!color || !isValidHexColor(color)) throw new Error("Cor primária inválida. Use o formato hexadecimal."); this.primaryColor = color; return this; } /** * Define a cor secundária * @param {string} color - Cor hexadecimal ou rgba * @returns {ModernProfile} - Instância atual para encadeamento */ setSecondaryColor(color) { this.secondaryColor = color; return this; } /** * Define a cor de destaque * @param {string} color - Cor hexadecimal * @returns {ModernProfile} - Instância atual para encadeamento */ setAccentColor(color) { if (!color || !isValidHexColor(color)) throw new Error("Cor de destaque inválida. Use o formato hexadecimal."); this.accentColor = color; return this; } /** * Ativa ou desativa a sombra de texto * @param {boolean} enabled - Se a sombra de texto deve ser ativada * @returns {ModernProfile} - Instância atual para encadeamento */ enableTextShadow(enabled = true) { this.useTextShadow = enabled; return this; } /** * Define as dimensões do card * @param {number} width - Largura do card em pixels * @param {number} height - Altura do card em pixels * @returns {ModernProfile} - Instância atual para encadeamento */ setCardDimensions(width, height) { if (typeof width !== "number" || width < 400 || width > 1200) { throw new Error("A largura do card deve estar entre 400 e 1200 pixels."); } if (typeof height !== "number" || height < 300 || height > 800) { throw new Error("A altura do card deve estar entre 300 e 800 pixels."); } this.cardWidth = width; this.cardHeight = height; return this; } /** * Define o raio dos cantos arredondados * @param {number} radius - Raio dos cantos em pixels * @returns {ModernProfile} - Instância atual para encadeamento */ setCornerRadius(radius) { if (typeof radius !== "number" || radius < 0) throw new Error("O raio dos cantos deve ser um número não negativo."); this.cornerRadius = radius; return this; } /** * Define o tamanho do avatar * @param {number} size - Tamanho do avatar em pixels * @returns {ModernProfile} - Instância atual para encadeamento */ setAvatarSize(size) { if (typeof size !== "number" || size < 80 || size > 200) { throw new Error("O tamanho do avatar deve estar entre 80 e 200 pixels."); } this.avatarSize = size; return this; } /** * Define a largura da borda do avatar * @param {number} width - Largura da borda em pixels * @returns {ModernProfile} - Instância atual para encadeamento */ setAvatarBorderWidth(width) { if (typeof width !== "number" || width < 0) throw new Error("A largura da borda do avatar deve ser um número não negativo."); this.avatarBorderWidth = width; return this; } // --- Método de Construção --- /** * Constrói o banner e retorna um buffer de imagem * @returns {Promise<Buffer>} - Buffer contendo a imagem do banner */ async build() { // --- Registro de Fonte --- const registeredFontName = await registerFontIfNeeded(this.font); // --- Configuração do Canvas --- const cardWidth = this.cardWidth; const cardHeight = this.cardHeight; const cornerRadius = this.cornerRadius; const avatarSize = this.avatarSize; const padding = 25; const canvas = pureimage.make(cardWidth, cardHeight); const ctx = canvas.getContext("2d"); // --- Desenha Plano de Fundo --- if (this.background.type === "gradient" || this.useGradientBackground) { // Plano de fundo com gradiente const gradient = applyMultiColorGradient( ctx, 0, 0, cardWidth, cardHeight, this.gradientColors, this.gradientDirection ); ctx.fillStyle = gradient; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, true, false); } else if (this.background.type === "color") { // Plano de fundo de cor sólida ctx.fillStyle = this.background.value; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, true, false); } else { // Plano de fundo de imagem try { ctx.save(); if (cornerRadius > 0) { roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, false, false); ctx.clip(); } const img = await loadImageWithAxios(this.background.value); const aspect = img.width / img.height; let drawWidth = cardWidth; let drawHeight = cardWidth / aspect; // Ajusta as dimensões para cobrir todo o card if (drawHeight < cardHeight) { drawHeight = cardHeight; drawWidth = cardHeight * aspect; } const offsetX = (cardWidth - drawWidth) / 2; const offsetY = (cardHeight - drawHeight) / 2; ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); // Aplica sobreposição para melhorar legibilidade ctx.fillStyle = "rgba(0, 0, 0, 0.3)"; ctx.fillRect(0, 0, cardWidth, cardHeight); ctx.restore(); } catch (e) { console.error("Falha ao desenhar imagem de plano de fundo:", e.message); // Fallback para gradiente const gradient = createLinearGradient( ctx, 0, 0, cardWidth, cardHeight, DEFAULT_COLORS.gradient.purple.start, DEFAULT_COLORS.gradient.purple.end, "diagonal" ); ctx.fillStyle = gradient; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, true, false); } } // --- Aplica Efeitos com base no Tema --- switch (this.theme) { case "glassmorphism": // Não aplica efeito no fundo, apenas nos elementos break; case "neomorphism": // Redefine o fundo para uma cor sólida clara ctx.fillStyle = "#E0E0E0"; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, true, false); // Aplica efeito de neomorfismo no card applyNeomorphism( ctx, padding / 2, padding / 2, cardWidth - padding, cardHeight - padding, cornerRadius - padding / 4, "#E0E0E0", false ); break; case "minimal": // Fundo branco simples ctx.fillStyle = "#FFFFFF"; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, true, false); // Borda sutil ctx.strokeStyle = "#EEEEEE"; ctx.lineWidth = 1; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, false, true); break; } // --- Desenha Avatar --- const avatarX = padding; const avatarY = padding; // Efeito de brilho no avatar (apenas para temas específicos) if (this.theme === "gradient" || this.theme === "glassmorphism") { applyGlow( ctx, avatarX - 5, avatarY - 5, avatarSize + 10, avatarSize + 10, avatarSize / 2 + 5, this.accentColor, 15 ); } ctx.save(); ctx.beginPath(); ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); try { const avatarImg = await loadImageWithAxios(this.avatar || path.join(__dirname, "../assets/placeholders/avatar.png")); ctx.drawImage(avatarImg, avatarX, avatarY, avatarSize, avatarSize); } catch (e) { console.error("Falha ao desenhar avatar:", e.message); // Avatar de fallback ctx.fillStyle = this.accentColor; ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize); ctx.fillStyle = "#FFFFFF"; ctx.font = `bold ${avatarSize / 3}px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(this.name.charAt(0).toUpperCase(), avatarX + avatarSize / 2, avatarY + avatarSize / 2); } ctx.restore(); // Borda do avatar ctx.strokeStyle = this.theme === "neomorphism" ? "#E0E0E0" : this.primaryColor; ctx.lineWidth = this.avatarBorderWidth; ctx.beginPath(); ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 + ctx.lineWidth / 2, 0, Math.PI * 2); ctx.stroke(); ctx.closePath(); // --- Desenha Indicador de Status --- const statusColor = USER_STATUS[this.status]?.color || USER_STATUS.offline.color; const statusSize = 24; const statusX = avatarX + avatarSize - statusSize * 0.7; const statusY = avatarY + avatarSize - statusSize * 0.7; ctx.fillStyle = statusColor; ctx.beginPath(); ctx.arc(statusX, statusY, statusSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.closePath(); ctx.strokeStyle = this.theme === "neomorphism" ? "#E0E0E0" : this.primaryColor; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(statusX, statusY, statusSize / 2, 0, Math.PI * 2); ctx.stroke(); ctx.closePath(); // --- Desenha Informações Principais --- const infoX = avatarX + avatarSize + padding; let infoY = avatarY + 10; const infoWidth = cardWidth - infoX - padding; // Aplica efeito de glassmorphism para a área de informações (apenas para tema glassmorphism) if (this.theme === "glassmorphism") { applyGlassmorphism( ctx, infoX - padding / 2, infoY - padding / 2, infoWidth + padding, avatarSize + padding, 10, 0.2, "#FFFFFF" ); } // Nome ctx.fillStyle = this.theme === "neomorphism" || this.theme === "minimal" ? "#333333" : this.primaryColor; ctx.font = `bold 24px ${registeredFontName}-Bold`; ctx.textAlign = "left"; ctx.textBaseline = "top"; // Aplica sombra de texto se ativada if (this.useTextShadow && this.theme !== "neomorphism" && this.theme !== "minimal") { applyTextShadow(ctx, "rgba(0, 0, 0, 0.5)", 3, 1, 1); } ctx.fillText(this.name, infoX, infoY); infoY += 30; // Remove sombra para o próximo texto if (this.useTextShadow && this.theme !== "neomorphism" && this.theme !== "minimal") { clearShadow(ctx); } // Título if (this.title) { ctx.fillStyle = this.theme === "neomorphism" || this.theme === "minimal" ? "#666666" : this.secondaryColor; ctx.font = `medium 16px ${registeredFontName}-Medium`; ctx.textAlign = "left"; ctx.fillText(this.title, infoX, infoY); infoY += 25; } // --- Desenha Bio --- const bioY = avatarY + avatarSize + padding; if (this.bio) { // Aplica efeito de glassmorphism para a área da bio (apenas para tema glassmorphism) if (this.theme === "glassmorphism") { applyGlassmorphism( ctx, padding, bioY - padding / 2, cardWidth - padding * 2, cardHeight / 3, 10, 0.2, "#FFFFFF" ); } ctx.fillStyle = this.theme === "neomorphism" || this.theme === "minimal" ? "#333333" : this.primaryColor; ctx.font = `regular 16px ${registeredFontName}-Regular`; ctx.textAlign = "left"; ctx.textBaseline = "top"; // Aplica sombra de texto se ativada if (this.useTextShadow && this.theme !== "neomorphism" && this.theme !== "minimal") { applyTextShadow(ctx, "rgba(0, 0, 0, 0.3)", 2, 1, 1); } wrapText(ctx, this.bio, padding, bioY, cardWidth - padding * 2, 20, registeredFontName); // Remove sombra if (this.useTextShadow && this.theme !== "neomorphism" && this.theme !== "minimal") { clearShadow(ctx); } } // --- Desenha Estatísticas --- if (this.stats.length > 0) { const statsY = bioY + (this.bio ? cardHeight / 3 + padding : padding); const statWidth = (cardWidth - padding * 2) / Math.min(this.stats.length, 3); // Aplica efeito de glassmorphism para a área de estatísticas (apenas para tema glassmorphism) if (this.theme === "glassmorphism") { applyGlassmorphism( ctx, padding, statsY - padding / 2, cardWidth - padding * 2, 80, 10, 0.2, "#FFFFFF" ); } this.stats.slice(0, 3).forEach((stat, index) => { const statX = padding + statWidth * index + statWidth / 2; // Valor da estatística ctx.fillStyle = this.theme === "neomorphism" || this.theme === "minimal" ? "#333333" : this.primaryColor; ctx.font = `bold 24px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "top"; // Aplica sombra de texto se ativada if (this.useTextShadow && this.theme !== "neomorphism" && this.theme !== "minimal") { applyTextShadow(ctx, "rgba(0, 0, 0, 0.3)", 2, 1, 1); } ctx.fillText(stat.value, statX, statsY); // Remove sombra para o próximo texto if (this.useTextShadow && this.theme !== "neomorphism" && this.theme !== "minimal") { clearShadow(ctx); } // Rótulo da estatística ctx.fillStyle = this.theme === "neomorphism" || this.theme === "minimal" ? "#666666" : this.secondaryColor; ctx.font = `regular 14px ${registeredFontName}-Regular`; ctx.fillText(stat.label, statX, statsY + 30); }); } // --- Desenha Distintivos --- if (this.badges.length > 0) { const badgesY = cardHeight - 60; const badgeSize = 40; const badgeSpacing = 10; let currentBadgeX = padding; // Aplica efeito de glassmorphism para a área de distintivos (apenas para tema glassmorphism) if (this.theme === "glassmorphism") { applyGlassmorphism( ctx, padding, badgesY - padding / 2, cardWidth - padding * 2, 60, 10, 0.2, "#FFFFFF" ); } for (const badge of this.badges.slice(0, 5)) { try { const badgeImg = await loadImageWithAxios(badge.url); ctx.drawImage(badgeImg, currentBadgeX, badgesY, badgeSize, badgeSize); currentBadgeX += badgeSize + badgeSpacing; } catch (e) { console.warn(`Falha ao carregar imagem do distintivo: ${badge.url}`, e.message); // Distintivo de fallback ctx.fillStyle = this.accentColor; ctx.beginPath(); ctx.arc(currentBadgeX + badgeSize / 2, badgesY + badgeSize / 2, badgeSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#FFFFFF"; ctx.font = `bold 16px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("?", currentBadgeX + badgeSize / 2, badgesY + badgeSize / 2); currentBadgeX += badgeSize + badgeSpacing; } } } // --- Desenha Links --- if (this.links.length > 0) { const linksY = cardHeight - 30; let currentLinkX = padding; ctx.fillStyle = this.theme === "neomorphism" || this.theme === "minimal" ? this.accentColor : this.primaryColor; ctx.font = `medium 14px ${registeredFontName}-Medium`; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; for (const link of this.links.slice(0, 3)) { const linkText = link.icon ? `${link.icon} ${link.label}` : link.label; ctx.fillText(linkText, currentLinkX, linksY); const linkWidth = ctx.measureText(linkText).width; currentLinkX += linkWidth + 20; } } // --- Codifica e Retorna Buffer --- try { return await encodeToBuffer(canvas); } catch (err) { console.error("Falha ao codificar o Perfil Moderno:", err); throw new Error("Não foi possível gerar o buffer de imagem do Perfil Moderno."); } } };