@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
JavaScript
"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.");
}
}
};