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