UNPKG

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