UNPKG

@cognima/banners

Version:

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

585 lines (514 loc) 20.9 kB
"use strict"; /** * Módulo de Banner de Perfil do Discord * * Este módulo gera banners de perfil no estilo do Discord com suporte a banner de fundo, * avatar, status, distintivos e informações personalizadas. * * @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"); /** * @class DiscordProfile * @classdesc Gera um banner de perfil no estilo do Discord com suporte completo a banner de fundo. * @example const profileCard = new DiscordProfile() * .setUsername("UsuárioDiscord") * .setDiscriminator("1234") * .setAvatar("avatar.png") * .setBanner("banner.png") * .setAboutMe("Desenvolvedor | Gamer | Entusiasta de IA") * .addBadge({ url: "badge1.png", description: "Nitro" }) * .setStatus("online") * .build(); */ module.exports = class DiscordProfile { constructor(options) { // Dados Principais this.username = "Usuário"; this.discriminator = "0000"; this.avatar = null; this.banner = null; this.aboutMe = null; this.badges = []; this.customFields = {}; this.memberSince = null; this.serverMemberSince = null; // Personalização Visual this.font = { name: options?.font?.name ?? DEFAULT_FONT_FAMILY, path: options?.font?.path }; this.backgroundColor = "#36393f"; this.secondaryColor = "#2f3136"; this.accentColor = "#5865f2"; this.textColor = "#ffffff"; this.secondaryTextColor = "#b9bbbe"; this.badgeBackgroundColor = "#2f3136"; this.avatarBorderColor = null; this.overlayOpacity = 0.4; this.status = { type: "offline", color: "#747F8D" }; // Configurações de Layout this.cornerRadius = 0; this.avatarSize = 128; this.avatarBorderWidth = 5; this.bannerHeight = 360; this.cardWidth = 1200; } // --- Setters para Dados Principais --- /** * Define o nome de usuário * @param {string} name - Nome do usuário * @returns {DiscordProfile} - Instância atual para encadeamento */ setUsername(name) { if (!name || typeof name !== "string") throw new Error("O nome de usuário deve ser uma string não vazia."); this.username = name; return this; } /** * Define o discriminador (tag) do usuário * @param {string} discrim - Discriminador (ex: "1234") * @returns {DiscordProfile} - Instância atual para encadeamento */ setDiscriminator(discrim) { if (!discrim || typeof discrim !== "string") throw new Error("O discriminador deve ser uma string não vazia."); this.discriminator = discrim; return this; } /** * Define a imagem do avatar * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar * @returns {DiscordProfile} - 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 a imagem do banner de fundo * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do banner * @returns {DiscordProfile} - Instância atual para encadeamento */ setBanner(image) { if (!image) throw new Error("A fonte da imagem do banner não pode estar vazia."); this.banner = image; return this; } /** * Define o texto "Sobre mim" * @param {string} text - Texto de descrição * @returns {DiscordProfile} - Instância atual para encadeamento */ setAboutMe(text) { if (text && typeof text !== "string") throw new Error("O texto 'Sobre mim' deve ser uma string se fornecido."); this.aboutMe = text; return this; } /** * Adiciona um distintivo ao perfil * @param {Object} badge - Objeto do distintivo com url e descrição * @returns {DiscordProfile} - 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; } /** * Define um campo personalizado * @param {string} title - Título do campo * @param {string} value - Valor do campo * @returns {DiscordProfile} - Instância atual para encadeamento */ setCustomField(title, value) { if (!title || typeof title !== "string") throw new Error("O título do campo personalizado deve ser uma string não vazia."); if (!value || typeof value !== "string") throw new Error("O valor do campo personalizado deve ser uma string não vazia."); this.customFields[title] = value; return this; } /** * Define a data de entrada no Discord * @param {string} date - Data de entrada (ex: "25 Mai 2020") * @returns {DiscordProfile} - Instância atual para encadeamento */ setMemberSince(date) { if (!date || typeof date !== "string") throw new Error("A data de entrada deve ser uma string não vazia."); this.memberSince = date; return this; } /** * Define a data de entrada no servidor * @param {string} date - Data de entrada no servidor (ex: "10 Jun 2021") * @returns {DiscordProfile} - Instância atual para encadeamento */ setServerMemberSince(date) { if (!date || typeof date !== "string") throw new Error("A data de entrada no servidor deve ser uma string não vazia."); this.serverMemberSince = date; return this; } // --- Setters para Personalização Visual --- /** * Define a cor de fundo principal * @param {string} color - Cor hexadecimal * @returns {DiscordProfile} - Instância atual para encadeamento */ setBackgroundColor(color) { if (!color || !isValidHexColor(color)) throw new Error("Cor de fundo inválida. Use o formato hexadecimal."); this.backgroundColor = color; return this; } /** * Define a cor secundária * @param {string} color - Cor hexadecimal * @returns {DiscordProfile} - Instância atual para encadeamento */ setSecondaryColor(color) { if (!color || !isValidHexColor(color)) throw new Error("Cor secundária inválida. Use o formato hexadecimal."); this.secondaryColor = color; return this; } /** * Define a cor de destaque * @param {string} color - Cor hexadecimal * @returns {DiscordProfile} - 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; } /** * Define a cor do texto principal * @param {string} color - Cor hexadecimal * @returns {DiscordProfile} - Instância atual para encadeamento */ setTextColor(color) { if (!color || !isValidHexColor(color)) throw new Error("Cor de texto inválida. Use o formato hexadecimal."); this.textColor = color; return this; } /** * Define a cor do texto secundário * @param {string} color - Cor hexadecimal * @returns {DiscordProfile} - Instância atual para encadeamento */ setSecondaryTextColor(color) { if (!color || !isValidHexColor(color)) throw new Error("Cor de texto secundário inválida. Use o formato hexadecimal."); this.secondaryTextColor = color; return this; } /** * Define a cor da borda do avatar * @param {string} color - Cor hexadecimal * @returns {DiscordProfile} - Instância atual para encadeamento */ setAvatarBorderColor(color) { if (color && !isValidHexColor(color)) throw new Error("Cor de borda do avatar inválida. Use o formato hexadecimal."); this.avatarBorderColor = color; return this; } /** * Define a opacidade da sobreposição * @param {number} opacity - Valor de opacidade (0-1) * @returns {DiscordProfile} - Instância atual para encadeamento */ setOverlayOpacity(opacity) { if (typeof opacity !== "number" || opacity < 0 || opacity > 1) throw new Error("A opacidade da sobreposição deve estar entre 0 e 1."); this.overlayOpacity = opacity; return this; } /** * Define o status do usuário * @param {string} type - Tipo de status ('online', 'idle', 'dnd', 'streaming', 'offline') * @returns {DiscordProfile} - Instância atual para encadeamento */ setStatus(type) { const validTypes = { online: "#43B581", idle: "#FAA61A", dnd: "#F04747", streaming: "#593695", offline: "#747F8D" }; if (!type || !validTypes[type.toLowerCase()]) throw new Error(`Tipo de status inválido. Use um dos seguintes: ${Object.keys(validTypes).join(", ")}`); this.status = { type: type.toLowerCase(), color: validTypes[type.toLowerCase()] }; return this; } /** * Define o raio dos cantos arredondados * @param {number} radius - Raio dos cantos em pixels * @returns {DiscordProfile} - 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 {DiscordProfile} - Instância atual para encadeamento */ setAvatarSize(size) { if (typeof size !== "number" || size < 64 || size > 256) throw new Error("O tamanho do avatar deve estar entre 64 e 256 pixels."); this.avatarSize = size; return this; } /** * Define a largura da borda do avatar * @param {number} width - Largura da borda em pixels * @returns {DiscordProfile} - 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; } /** * Define a altura do banner * @param {number} height - Altura do banner em pixels * @returns {DiscordProfile} - Instância atual para encadeamento */ setBannerHeight(height) { if (typeof height !== "number" || height < 100 || height > 300) throw new Error("A altura do banner deve estar entre 100 e 300 pixels."); this.bannerHeight = height; return this; } /** * Define a largura do card * @param {number} width - Largura do card em pixels * @returns {DiscordProfile} - Instância atual para encadeamento */ setCardWidth(width) { if (typeof width !== "number" || width < 400 || width > 1000) throw new Error("A largura do card deve estar entre 400 e 1000 pixels."); this.cardWidth = width; return this; } // --- Método de Construção --- /** * Constrói o banner de perfil e retorna um buffer de imagem * @returns {Promise<Buffer>} - Buffer contendo a imagem do banner */ async build() { if (!this.avatar) throw new Error("A imagem do avatar deve ser definida usando setAvatar()."); // --- Registro de Fonte --- const registeredFontName = await registerFontIfNeeded(this.font); // --- Configuração do Canvas --- const cardWidth = this.cardWidth; const headerHeight = this.bannerHeight; const avatarSize = this.avatarSize; const avatarOverlap = 40; const bodyPadding = 25; const contentStartY = headerHeight - avatarOverlap + avatarSize / 2 + bodyPadding; const badgeAreaHeight = this.badges.length > 0 ? 80 : 0; // Estima a altura necessária para o conteúdo let estimatedContentHeight = 0; if (this.aboutMe) estimatedContentHeight += 80; estimatedContentHeight += Object.keys(this.customFields).length * 45; if (this.memberSince) estimatedContentHeight += 30; if (this.serverMemberSince) estimatedContentHeight += 30; let cardHeight = contentStartY + Math.max(100, estimatedContentHeight) + badgeAreaHeight + bodyPadding; const borderRadius = this.cornerRadius; const statusIndicatorSize = 32; const canvas = pureimage.make(cardWidth, cardHeight); const ctx = canvas.getContext("2d"); // --- Desenha Plano de Fundo Principal --- ctx.fillStyle = this.backgroundColor; roundRect(ctx, 0, 0, cardWidth, cardHeight, borderRadius, true, false); // --- Desenha Banner de Fundo --- ctx.save(); // Define o caminho de recorte para o cabeçalho (apenas cantos superiores arredondados) ctx.beginPath(); ctx.moveTo(0, headerHeight); // Inicia no canto inferior esquerdo ctx.lineTo(0, borderRadius); // Borda esquerda até o raio ctx.quadraticCurveTo(0, 0, borderRadius, 0); // Canto superior esquerdo ctx.lineTo(cardWidth - borderRadius, 0); // Borda superior ctx.quadraticCurveTo(cardWidth, 0, cardWidth, borderRadius); // Canto superior direito ctx.lineTo(cardWidth, headerHeight); // Borda direita para baixo ctx.closePath(); // Fecha o caminho de volta para o canto inferior direito (implicitamente) ctx.clip(); ctx.globalAlpha = 1; if (this.banner) { try { const img = await loadImageWithAxios(this.banner); const aspect = img.width / img.height; let drawWidth = cardWidth; let drawHeight = cardWidth / aspect; if (drawHeight < headerHeight) { drawHeight = headerHeight; drawWidth = headerHeight * aspect; } const offsetX = (cardWidth - drawWidth) / 2; const offsetY = 0; ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); } catch (e) { console.error("Falha ao desenhar imagem de banner:", e.message); ctx.fillStyle = this.accentColor; ctx.fillRect(0, 0, cardWidth, headerHeight); } } else { // Banner de cor sólida se nenhuma imagem for fornecida ctx.fillStyle = this.accentColor; ctx.fillRect(0, 0, cardWidth, headerHeight); } ctx.restore(); ctx.globalAlpha = 1; // --- Desenha Avatar --- const avatarX = bodyPadding; const avatarY = headerHeight - avatarOverlap - avatarSize / 2; 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); ctx.drawImage(avatarImg, avatarX, avatarY, avatarSize, avatarSize); } catch (e) { console.error("Falha ao desenhar imagem do avatar:", e.message); ctx.fillStyle = "#555"; ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize); ctx.fillStyle = "#FFF"; ctx.font = `bold 30px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("?", avatarX + avatarSize / 2, avatarY + avatarSize / 2); } ctx.restore(); // --- Desenha Borda do Avatar --- if (this.avatarBorderColor) { ctx.strokeStyle = this.avatarBorderColor; } else { ctx.strokeStyle = this.backgroundColor; // Usa a cor de fundo como padrão } 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 --- ctx.fillStyle = this.status.color; ctx.beginPath(); const statusX = avatarX + avatarSize - statusIndicatorSize * 0.7; const statusY = avatarY + avatarSize - statusIndicatorSize * 0.7; ctx.arc(statusX, statusY, statusIndicatorSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.closePath(); ctx.strokeStyle = this.backgroundColor; ctx.lineWidth = 3; ctx.stroke(); // --- Desenha Nome de Usuário e Discriminador --- ctx.fillStyle = this.textColor; const usernameFont = `28px ${registeredFontName}-Bold`; ctx.font = usernameFont; ctx.textAlign = "start"; ctx.textBaseline = "top"; const usernameX = avatarX + avatarSize + bodyPadding; const usernameY = headerHeight + 5; // Aplica sombra de texto para melhor legibilidade applyTextShadow(ctx); const usernameText = this.username.length > 20 ? this.username.slice(0, 17) + "..." : this.username; ctx.fillText(usernameText, usernameX, usernameY); // Desenha o discriminador ctx.fillStyle = this.secondaryTextColor; const discrimX = usernameX + ctx.measureText(usernameText).width + 5; ctx.font = `20px ${registeredFontName}-Regular`; ctx.fillText(`#${this.discriminator}`, discrimX, usernameY + 5); clearShadow(ctx); // --- Desenha Conteúdo Abaixo do Avatar --- let currentY = contentStartY; const contentX = bodyPadding; const contentWidth = cardWidth - 2 * bodyPadding; // Seção "Sobre mim" if (this.aboutMe) { // Título da seção ctx.fillStyle = this.textColor; ctx.font = `18px ${registeredFontName}-Bold`; ctx.textAlign = "start"; ctx.textBaseline = "top"; ctx.fillText("SOBRE MIM", contentX, currentY); currentY += 25; // Conteúdo da seção ctx.fillStyle = this.secondaryTextColor; ctx.font = `16px ${registeredFontName}-Regular`; currentY = wrapText(ctx, this.aboutMe, contentX, currentY, contentWidth, 22, registeredFontName) + 15; } // Campos Personalizados if (Object.keys(this.customFields).length > 0) { currentY += 10; ctx.textBaseline = "top"; for (const title in this.customFields) { // Título do campo ctx.fillStyle = this.textColor; ctx.font = `16px ${registeredFontName}-Bold`; ctx.fillText(title.toUpperCase(), contentX, currentY); currentY += 22; // Valor do campo ctx.fillStyle = this.secondaryTextColor; ctx.font = `16px ${registeredFontName}-Regular`; const valueText = this.customFields[title]; currentY = wrapText(ctx, valueText, contentX, currentY, contentWidth, 20, registeredFontName) + 15; } } // Informações de Membro if (this.memberSince || this.serverMemberSince) { currentY += 10; ctx.fillStyle = this.textColor; ctx.font = `16px ${registeredFontName}-Bold`; ctx.fillText("MEMBRO DESDE", contentX, currentY); currentY += 22; if (this.memberSince) { ctx.fillStyle = this.secondaryTextColor; ctx.font = `16px ${registeredFontName}-Regular`; ctx.fillText(`Discord: ${this.memberSince}`, contentX, currentY); currentY += 22; } if (this.serverMemberSince) { ctx.fillStyle = this.secondaryTextColor; ctx.font = `16px ${registeredFontName}-Regular`; ctx.fillText(`Servidor: ${this.serverMemberSince}`, contentX, currentY); currentY += 22; } } // --- Desenha Área de Distintivos --- if (this.badges.length > 0) { const badgeAreaY = cardHeight - badgeAreaHeight; ctx.fillStyle = this.secondaryColor; roundRect(ctx, 0, badgeAreaY, cardWidth, badgeAreaHeight, { tl: 0, tr: 0, br: borderRadius, bl: borderRadius }, true, false); const badgeSize = 40; const badgePadding = 15; let currentBadgeX = bodyPadding; const badgeY = badgeAreaY + (badgeAreaHeight - badgeSize) / 2; // Desenha os distintivos currentBadgeX = bodyPadding; for (const badge of this.badges.slice(0, 10)) { try { const badgeImg = await loadImageWithAxios(badge.url); ctx.drawImage(badgeImg, currentBadgeX, badgeY, badgeSize, badgeSize); currentBadgeX += badgeSize + badgePadding; } catch (e) { console.warn(`Falha ao carregar imagem do distintivo: ${badge.url}`, e.message); ctx.fillStyle = "#555"; ctx.fillRect(currentBadgeX, badgeY, badgeSize, badgeSize); currentBadgeX += badgeSize + badgePadding; } } } // --- Codifica e Retorna Buffer --- try { return await encodeToBuffer(canvas); } catch (err) { console.error("Falha ao codificar o card de Perfil do Discord:", err); throw new Error("Não foi possível gerar o buffer de imagem do card de Perfil do Discord."); } } };