@cognima/banners
Version:
Biblioteca avançada para geração de banners dinâmicos para diversas plataformas e aplicações
545 lines (469 loc) • 17.4 kB
JavaScript
"use strict";
/**
* Módulo de Banner de Cabeçalho do Telegram
*
* Este módulo gera banners no estilo de cabeçalho de canal do Telegram com
* imagem de capa, avatar, título, descrição e contadores.
*
* @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
} = require("./constants");
const {
applyGlassmorphism
} = require("./effects");
/**
* @class TelegramHeader
* @classdesc Gera um banner no estilo de cabeçalho de canal do Telegram.
* @example const header = new TelegramHeader()
* .setTitle("Canal de Notícias")
* .setDescription("Atualizações diárias sobre tecnologia e ciência")
* .setAvatar("avatar.png")
* .setCoverImage("cover.jpg")
* .setSubscribers(15000)
* .build();
*/
module.exports = class TelegramHeader {
constructor(options) {
// Dados Principais
this.title = "Nome do Canal";
this.description = null;
this.avatar = null;
this.coverImage = null;
this.subscribers = 0;
this.posts = 0;
this.isVerified = false;
this.isPublic = true;
this.link = null;
// Personalização Visual
this.font = { name: options?.font?.name ?? DEFAULT_FONT_FAMILY, path: options?.font?.path };
this.primaryColor = DEFAULT_COLORS.telegram.primary;
this.backgroundColor = DEFAULT_COLORS.telegram.light;
this.textColor = DEFAULT_COLORS.text.dark;
this.secondaryTextColor = DEFAULT_COLORS.text.muted;
this.useGlassmorphism = false;
this.useTextShadow = false;
// Configurações de Layout
this.cardWidth = DEFAULT_DIMENSIONS.banner.width;
this.cardHeight = 300;
this.cornerRadius = LAYOUT.cornerRadius.medium;
this.avatarSize = 100;
}
// --- Setters para Dados Principais ---
/**
* Define o título do canal
* @param {string} text - Título do canal
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
setTitle(text) {
if (!text || typeof text !== "string") throw new Error("O título do canal deve ser uma string não vazia.");
this.title = text;
return this;
}
/**
* Define a descrição do canal
* @param {string} text - Descrição do canal
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
setDescription(text) {
if (!text || typeof text !== "string") throw new Error("A descrição do canal deve ser uma string não vazia.");
this.description = text;
return this;
}
/**
* Define o avatar do canal
* @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar
* @returns {TelegramHeader} - 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 de capa do canal
* @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem de capa
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
setCoverImage(image) {
if (!image) throw new Error("A fonte da imagem de capa não pode estar vazia.");
this.coverImage = image;
return this;
}
/**
* Define o número de inscritos
* @param {number} count - Número de inscritos
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
setSubscribers(count) {
if (typeof count !== "number" || count < 0) throw new Error("O número de inscritos deve ser um número não negativo.");
this.subscribers = count;
return this;
}
/**
* Define o número de posts
* @param {number} count - Número de posts
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
setPosts(count) {
if (typeof count !== "number" || count < 0) throw new Error("O número de posts deve ser um número não negativo.");
this.posts = count;
return this;
}
/**
* Define se o canal é verificado
* @param {boolean} isVerified - Se o canal é verificado
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
setVerified(isVerified = true) {
this.isVerified = !!isVerified;
return this;
}
/**
* Define se o canal é público
* @param {boolean} isPublic - Se o canal é público
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
setPublic(isPublic = true) {
this.isPublic = !!isPublic;
return this;
}
/**
* Define o link do canal
* @param {string} link - Link do canal (ex: "t.me/canalexemplo")
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
setLink(link) {
if (!link || typeof link !== "string") throw new Error("O link do canal deve ser uma string não vazia.");
this.link = link;
return this;
}
// --- Setters para Personalização Visual ---
/**
* Define a cor primária
* @param {string} color - Cor hexadecimal
* @returns {TelegramHeader} - 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 de fundo
* @param {string} color - Cor hexadecimal
* @returns {TelegramHeader} - 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 do texto principal
* @param {string} color - Cor hexadecimal
* @returns {TelegramHeader} - 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 {TelegramHeader} - 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;
}
/**
* Ativa ou desativa o efeito de glassmorphism
* @param {boolean} enabled - Se o efeito deve ser ativado
* @returns {TelegramHeader} - Instância atual para encadeamento
*/
enableGlassmorphism(enabled = true) {
this.useGlassmorphism = enabled;
return this;
}
/**
* Ativa ou desativa a sombra de texto
* @param {boolean} enabled - Se a sombra de texto deve ser ativada
* @returns {TelegramHeader} - 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 {TelegramHeader} - Instância atual para encadeamento
*/
setCardDimensions(width, height) {
if (typeof width !== "number" || width < 600 || width > 1920) {
throw new Error("A largura do card deve estar entre 600 e 1920 pixels.");
}
if (typeof height !== "number" || height < 200 || height > 600) {
throw new Error("A altura do card deve estar entre 200 e 600 pixels.");
}
this.cardWidth = width;
this.cardHeight = height;
return this;
}
/**
* Define o raio dos cantos arredondados
* @param {number} radius - Raio dos cantos em pixels
* @returns {TelegramHeader} - 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 {TelegramHeader} - Instância atual para encadeamento
*/
setAvatarSize(size) {
if (typeof size !== "number" || size < 60 || size > 200) {
throw new Error("O tamanho do avatar deve estar entre 60 e 200 pixels.");
}
this.avatarSize = size;
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 coverHeight = cardHeight * 0.6;
const avatarSize = this.avatarSize;
const padding = 20;
const cornerRadius = this.cornerRadius;
const canvas = pureimage.make(cardWidth, cardHeight);
const ctx = canvas.getContext("2d");
// --- Desenha Plano de Fundo ---
ctx.fillStyle = this.backgroundColor;
ctx.fillRect(0, 0, cardWidth, cardHeight);
// --- Desenha Imagem de Capa ---
if (this.coverImage) {
try {
ctx.save();
// Define o caminho de recorte para a capa (apenas cantos superiores arredondados)
ctx.beginPath();
ctx.moveTo(0, coverHeight); // Inicia no canto inferior esquerdo
ctx.lineTo(0, cornerRadius); // Borda esquerda até o raio
ctx.quadraticCurveTo(0, 0, cornerRadius, 0); // Canto superior esquerdo
ctx.lineTo(cardWidth - cornerRadius, 0); // Borda superior
ctx.quadraticCurveTo(cardWidth, 0, cardWidth, cornerRadius); // Canto superior direito
ctx.lineTo(cardWidth, coverHeight); // Borda direita para baixo
ctx.closePath(); // Fecha o caminho de volta para o canto inferior direito (implicitamente)
ctx.clip();
const img = await loadImageWithAxios(this.coverImage);
const aspect = img.width / img.height;
let drawWidth = cardWidth;
let drawHeight = cardWidth / aspect;
// Ajusta as dimensões para cobrir toda a área da capa
if (drawHeight < coverHeight) {
drawHeight = coverHeight;
drawWidth = coverHeight * aspect;
}
const offsetX = (cardWidth - drawWidth) / 2;
const offsetY = 0;
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
// Aplica sobreposição para melhorar legibilidade
ctx.fillStyle = hexToRgba("#000000", 0.3);
ctx.fillRect(0, 0, cardWidth, coverHeight);
ctx.restore();
} catch (e) {
console.error("Falha ao desenhar imagem de capa:", e.message);
}
}
// --- Desenha Avatar ---
const avatarX = padding;
const avatarY = coverHeight - 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 || 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.primaryColor;
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.title.charAt(0).toUpperCase(), avatarX + avatarSize / 2, avatarY + avatarSize / 2);
}
ctx.restore();
// Borda do avatar
ctx.strokeStyle = this.backgroundColor;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 + ctx.lineWidth / 2, 0, Math.PI * 2);
ctx.stroke();
ctx.closePath();
// --- Desenha Área de Informações ---
const infoX = avatarX + avatarSize + padding;
const infoY = coverHeight + padding;
const infoWidth = cardWidth - infoX - padding;
// Aplica efeito de glassmorphism se ativado
if (this.useGlassmorphism) {
applyGlassmorphism(
ctx,
infoX - padding / 2,
coverHeight - padding / 2,
cardWidth - infoX,
cardHeight - coverHeight + padding,
cornerRadius,
0.2,
"#FFFFFF"
);
}
// --- Desenha Título ---
ctx.fillStyle = this.textColor;
ctx.font = `bold 24px ${registeredFontName}-Bold`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
// Aplica sombra de texto se ativada
if (this.useTextShadow) {
applyTextShadow(ctx, "rgba(0, 0, 0, 0.5)", 3, 1, 1);
}
const titleText = this.title;
const titleWidth = ctx.measureText(titleText).width;
ctx.fillText(titleText, infoX, infoY);
// Desenha ícone de verificado (se aplicável)
if (this.isVerified) {
const verifiedSize = 20;
const verifiedX = infoX + titleWidth + 10;
ctx.fillStyle = this.primaryColor;
ctx.beginPath();
ctx.arc(verifiedX + verifiedSize / 2, infoY + 12, verifiedSize / 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#FFFFFF";
ctx.font = `bold 14px ${registeredFontName}-Bold`;
ctx.textAlign = "center";
ctx.fillText("✓", verifiedX + verifiedSize / 2, infoY + 12);
}
// Remove sombra para o próximo texto
if (this.useTextShadow) {
clearShadow(ctx);
}
// --- Desenha Descrição ---
if (this.description) {
ctx.fillStyle = this.secondaryTextColor;
ctx.font = `regular 16px ${registeredFontName}-Regular`;
ctx.textAlign = "left";
const descriptionY = infoY + 35;
wrapText(ctx, this.description, infoX, descriptionY, infoWidth, 20, registeredFontName);
}
// --- Desenha Contadores ---
const counterY = cardHeight - padding - 20;
let counterX = infoX;
// Contador de inscritos
if (this.subscribers > 0) {
ctx.fillStyle = this.secondaryTextColor;
ctx.font = `medium 16px ${registeredFontName}-Medium`;
ctx.textAlign = "left";
const subscribersText = `${this._formatNumber(this.subscribers)} inscritos`;
ctx.fillText(subscribersText, counterX, counterY);
counterX += ctx.measureText(subscribersText).width + 20;
}
// Contador de posts
if (this.posts > 0) {
ctx.fillStyle = this.secondaryTextColor;
ctx.font = `medium 16px ${registeredFontName}-Medium`;
ctx.textAlign = "left";
const postsText = `${this._formatNumber(this.posts)} publicações`;
ctx.fillText(postsText, counterX, counterY);
}
// --- Desenha Link ---
if (this.link) {
ctx.fillStyle = this.primaryColor;
ctx.font = `medium 16px ${registeredFontName}-Medium`;
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText(this.link, cardWidth - padding, cardHeight - padding);
}
// --- Desenha Ícone de Privacidade ---
const privacyIconSize = 16;
const privacyIconX = cardWidth - padding - privacyIconSize;
const privacyIconY = infoY;
ctx.fillStyle = this.isPublic ? this.primaryColor : this.secondaryTextColor;
ctx.beginPath();
if (this.isPublic) {
// Ícone de canal público (globo)
ctx.arc(privacyIconX + privacyIconSize / 2, privacyIconY + privacyIconSize / 2, privacyIconSize / 2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#FFFFFF";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(privacyIconX + privacyIconSize / 2, privacyIconY + privacyIconSize / 2, privacyIconSize / 3, 0, Math.PI * 2);
ctx.stroke();
} else {
// Ícone de canal privado (cadeado)
ctx.fillRect(privacyIconX, privacyIconY + privacyIconSize / 3, privacyIconSize, privacyIconSize * 2/3);
ctx.beginPath();
ctx.arc(privacyIconX + privacyIconSize / 2, privacyIconY + privacyIconSize / 3, privacyIconSize / 3, Math.PI, 0);
ctx.fill();
}
// --- Codifica e Retorna Buffer ---
try {
return await encodeToBuffer(canvas);
} catch (err) {
console.error("Falha ao codificar o Cabeçalho do Telegram:", err);
throw new Error("Não foi possível gerar o buffer de imagem do Cabeçalho do Telegram.");
}
}
// --- Métodos Auxiliares Privados ---
/**
* Formata um número para exibição amigável
* @private
*/
_formatNumber(num) {
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();
}
};