UNPKG

@cognima/banners

Version:

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

680 lines (571 loc) 21.6 kB
"use strict"; /** * Módulo de Banner de Post do Facebook * * Este módulo gera banners no estilo de posts do Facebook com * elementos visuais característicos da plataforma. * * @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, formatNumber } = require("../utils"); const { DEFAULT_COLORS, LAYOUT, DEFAULT_DIMENSIONS } = require("./constants"); /** * @class FacebookPost * @classdesc Gera um banner no estilo de post do Facebook. * @example const post = new FacebookPost() * .setName("Nome Completo") * .setContent("Conteúdo do post") * .setImage("imagem.jpg") * .setLikes(1500) * .setComments(200) * .setShares(50) * .build(); */ module.exports = class FacebookPost { constructor(options) { // Dados Principais this.name = "Nome Completo"; this.avatar = null; this.content = "Conteúdo do post"; this.image = null; this.likes = 0; this.comments = 0; this.shares = 0; this.postTime = "1h"; this.isVerified = false; this.privacy = "public"; // public, friends, private this.isPagePost = false; this.pageName = null; this.pageLogo = null; this.reactions = { like: 0, love: 0, care: 0, haha: 0, wow: 0, sad: 0, angry: 0 }; // Personalização Visual this.font = { name: options?.font?.name ?? DEFAULT_FONT_FAMILY, path: options?.font?.path }; this.theme = "light"; // light, dark this.postType = "standard"; // standard, photo, video, shared, event this.cornerRadius = LAYOUT.cornerRadius.small; // Configurações de Layout this.cardWidth = DEFAULT_DIMENSIONS.post.width; this.cardHeight = 700; } // --- Setters para Dados Principais --- /** * Define o nome completo * @param {string} text - Nome completo * @returns {FacebookPost} - 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 avatar * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar * @returns {FacebookPost} - 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 conteúdo do post * @param {string} text - Texto do conteúdo * @returns {FacebookPost} - Instância atual para encadeamento */ setContent(text) { if (!text || typeof text !== "string") throw new Error("O conteúdo deve ser uma string não vazia."); this.content = text; return this; } /** * Define a imagem principal do post * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem * @returns {FacebookPost} - Instância atual para encadeamento */ setImage(image) { if (!image) throw new Error("A fonte da imagem não pode estar vazia."); this.image = image; return this; } /** * Define o número de curtidas * @param {number} count - Número de curtidas * @returns {FacebookPost} - Instância atual para encadeamento */ setLikes(count) { if (typeof count !== "number" || count < 0) throw new Error("O número de curtidas deve ser um número não negativo."); this.likes = count; return this; } /** * Define o número de comentários * @param {number} count - Número de comentários * @returns {FacebookPost} - Instância atual para encadeamento */ setComments(count) { if (typeof count !== "number" || count < 0) throw new Error("O número de comentários deve ser um número não negativo."); this.comments = count; return this; } /** * Define o número de compartilhamentos * @param {number} count - Número de compartilhamentos * @returns {FacebookPost} - Instância atual para encadeamento */ setShares(count) { if (typeof count !== "number" || count < 0) throw new Error("O número de compartilhamentos deve ser um número não negativo."); this.shares = count; return this; } /** * Define o tempo de publicação * @param {string} text - Tempo de publicação (ex: "1h", "2d", "1sem") * @returns {FacebookPost} - Instância atual para encadeamento */ setPostTime(text) { if (!text || typeof text !== "string") throw new Error("O tempo de publicação deve ser uma string não vazia."); this.postTime = text; return this; } /** * Define se o usuário é verificado * @param {boolean} isVerified - Se o usuário é verificado * @returns {FacebookPost} - Instância atual para encadeamento */ setVerified(isVerified = true) { this.isVerified = !!isVerified; return this; } /** * Define a privacidade do post * @param {string} privacy - Privacidade ('public', 'friends', 'private') * @returns {FacebookPost} - Instância atual para encadeamento */ setPrivacy(privacy) { const validPrivacy = ["public", "friends", "private"]; if (!privacy || !validPrivacy.includes(privacy.toLowerCase())) { throw new Error(`Privacidade inválida. Use uma das seguintes: ${validPrivacy.join(", ")}`); } this.privacy = privacy.toLowerCase(); return this; } /** * Define se é um post de página * @param {boolean} isPagePost - Se é um post de página * @param {string} pageName - Nome da página * @param {string|Buffer|Object} pageLogo - URL, Buffer ou caminho do logo da página * @returns {FacebookPost} - Instância atual para encadeamento */ setPagePost(isPagePost = true, pageName = null, pageLogo = null) { this.isPagePost = !!isPagePost; if (isPagePost) { if (!pageName || typeof pageName !== "string") { throw new Error("O nome da página deve ser uma string não vazia."); } this.pageName = pageName; this.pageLogo = pageLogo; } return this; } /** * Define as reações do post * @param {Object} reactions - Objeto com as contagens de reações * @returns {FacebookPost} - Instância atual para encadeamento */ setReactions(reactions) { if (!reactions || typeof reactions !== "object") { throw new Error("As reações devem ser um objeto."); } const validReactions = ["like", "love", "care", "haha", "wow", "sad", "angry"]; for (const [key, value] of Object.entries(reactions)) { if (validReactions.includes(key)) { if (typeof value !== "number" || value < 0) { throw new Error(`O valor da reação "${key}" deve ser um número não negativo.`); } this.reactions[key] = value; } } // Atualiza o total de curtidas this.likes = Object.values(this.reactions).reduce((a, b) => a + b, 0); return this; } // --- Setters para Personalização Visual --- /** * Define o tema * @param {string} theme - Tema ('light', 'dark') * @returns {FacebookPost} - Instância atual para encadeamento */ setTheme(theme) { const validThemes = ["light", "dark"]; 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 o tipo de post * @param {string} type - Tipo de post ('standard', 'photo', 'video', 'shared', 'event') * @returns {FacebookPost} - Instância atual para encadeamento */ setPostType(type) { const validTypes = ["standard", "photo", "video", "shared", "event"]; if (!type || !validTypes.includes(type.toLowerCase())) { throw new Error(`Tipo de post inválido. Use um dos seguintes: ${validTypes.join(", ")}`); } this.postType = type.toLowerCase(); return this; } /** * Define o raio dos cantos arredondados * @param {number} radius - Raio dos cantos em pixels * @returns {FacebookPost} - 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 as dimensões do card * @param {number} width - Largura do card em pixels * @param {number} height - Altura do card em pixels * @returns {FacebookPost} - 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 < 400 || height > 1200) { throw new Error("A altura do card deve estar entre 400 e 1200 pixels."); } this.cardWidth = width; this.cardHeight = height; 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 padding = 16; const canvas = pureimage.make(cardWidth, cardHeight); const ctx = canvas.getContext("2d"); // --- Configuração de Cores com base no Tema --- const colors = this._getThemeColors(); // --- Desenha Plano de Fundo --- ctx.fillStyle = colors.background; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, true, false); // --- Desenha Cabeçalho do Post --- const headerHeight = 70; // Avatar const avatarSize = 50; const avatarX = padding; const avatarY = padding; 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 = "#1877F2"; 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(); // Informações do usuário const infoX = avatarX + avatarSize + 10; let infoY = avatarY + 5; // Nome ctx.fillStyle = colors.text; ctx.font = `bold 16px ${registeredFontName}-Bold`; ctx.textAlign = "left"; ctx.textBaseline = "top"; const nameText = this.name; const nameWidth = ctx.measureText(nameText).width; ctx.fillText(nameText, infoX, infoY); // Ícone de verificado (se aplicável) if (this.isVerified) { const verifiedSize = 16; const verifiedX = infoX + nameWidth + 5; ctx.fillStyle = "#1877F2"; ctx.beginPath(); ctx.arc(verifiedX + verifiedSize / 2, infoY + verifiedSize / 2, verifiedSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#FFFFFF"; ctx.font = `bold 12px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.fillText("✓", verifiedX + verifiedSize / 2, infoY + verifiedSize / 2); } // Página (se aplicável) if (this.isPagePost) { infoY += 20; ctx.fillStyle = colors.textSecondary; ctx.font = `regular 14px ${registeredFontName}-Regular`; ctx.textAlign = "left"; ctx.fillText(this.pageName, infoX, infoY); } // Tempo e privacidade infoY = this.isPagePost ? infoY + 20 : infoY + 25; ctx.fillStyle = colors.textSecondary; ctx.font = `regular 14px ${registeredFontName}-Regular`; ctx.textAlign = "left"; let privacyIcon = "🌎"; if (this.privacy === "friends") { privacyIcon = "👥"; } else if (this.privacy === "private") { privacyIcon = "🔒"; } ctx.fillText(`${this.postTime} • ${privacyIcon}`, infoX, infoY); // Botão de mais opções const moreButtonX = cardWidth - padding - 20; const moreButtonY = padding + 25; ctx.fillStyle = colors.textSecondary; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("•••", moreButtonX, moreButtonY); // --- Desenha Conteúdo do Post --- let contentY = headerHeight + padding; // Texto do post if (this.content) { ctx.fillStyle = colors.text; ctx.font = `regular 16px ${registeredFontName}-Regular`; ctx.textAlign = "left"; ctx.textBaseline = "top"; contentY = wrapText(ctx, this.content, padding, contentY, cardWidth - padding * 2, 24, registeredFontName); contentY += padding; } // Imagem (se fornecida) if (this.image) { try { const imageHeight = 300; const imageY = contentY; ctx.save(); // Recorta a imagem com cantos arredondados roundRect(ctx, padding, imageY, cardWidth - padding * 2, imageHeight, cornerRadius, false, false); ctx.clip(); const img = await loadImageWithAxios(this.image); const aspect = img.width / img.height; const imageWidth = cardWidth - padding * 2; // Ajusta as dimensões para manter a proporção let drawWidth = imageWidth; let drawHeight = imageWidth / aspect; if (drawHeight > imageHeight) { drawHeight = imageHeight; drawWidth = imageHeight * aspect; } const offsetX = padding + (imageWidth - drawWidth) / 2; const offsetY = imageY + (imageHeight - drawHeight) / 2; ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); ctx.restore(); contentY = imageY + imageHeight + padding; } catch (e) { console.error("Falha ao desenhar imagem:", e.message); } } // --- Desenha Contadores de Reações --- const reactionsY = contentY; const reactionsHeight = 30; // Ícones de reações const reactionIcons = { like: "👍", love: "❤️", care: "🤗", haha: "😄", wow: "😮", sad: "😢", angry: "😠" }; // Desenha ícones de reações let hasReactions = false; const activeReactions = Object.entries(this.reactions).filter(([_, count]) => count > 0); if (activeReactions.length > 0) { hasReactions = true; // Fundo dos ícones ctx.fillStyle = colors.reactionBackground; roundRect(ctx, padding, reactionsY, 80, reactionsHeight, reactionsHeight / 2, true, false); // Desenha até 3 ícones de reações const iconSize = 20; const iconSpacing = 15; let iconX = padding + 10; activeReactions.slice(0, 3).forEach(([reaction, _]) => { ctx.fillStyle = colors.text; ctx.font = `regular 16px ${registeredFontName}-Regular`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(reactionIcons[reaction], iconX, reactionsY + reactionsHeight / 2); iconX += iconSpacing; }); // Contador de reações ctx.fillStyle = colors.textSecondary; ctx.font = `regular 14px ${registeredFontName}-Regular`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(formatNumber(this.likes), padding + 65, reactionsY + reactionsHeight / 2); // Contadores de comentários e compartilhamentos ctx.textAlign = "right"; let counterText = ""; if (this.comments > 0 && this.shares > 0) { counterText = `${formatNumber(this.comments)} comentários • ${formatNumber(this.shares)} compartilhamentos`; } else if (this.comments > 0) { counterText = `${formatNumber(this.comments)} comentários`; } else if (this.shares > 0) { counterText = `${formatNumber(this.shares)} compartilhamentos`; } if (counterText) { ctx.fillText(counterText, cardWidth - padding, reactionsY + reactionsHeight / 2); } contentY = reactionsY + reactionsHeight + padding; } // --- Desenha Barra de Interações --- const interactionBarY = contentY; const interactionBarHeight = 50; // Linha separadora ctx.strokeStyle = colors.separator; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padding, interactionBarY); ctx.lineTo(cardWidth - padding, interactionBarY); ctx.stroke(); // Ícones de interação const iconSpacing = (cardWidth - padding * 2) / 3; const iconY = interactionBarY + interactionBarHeight / 2; // Ícone de curtida ctx.fillStyle = colors.textSecondary; ctx.font = `regular 14px ${registeredFontName}-Regular`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("👍 Curtir", padding + iconSpacing / 2, iconY); // Ícone de comentário ctx.fillText("💬 Comentar", padding + iconSpacing * 1.5, iconY); // Ícone de compartilhamento ctx.fillText("↗ Compartilhar", padding + iconSpacing * 2.5, iconY); // --- Desenha Caixa de Comentário --- const commentBoxY = interactionBarY + interactionBarHeight + padding; const commentBoxHeight = 60; if (commentBoxY + commentBoxHeight <= cardHeight - padding) { // Linha separadora ctx.strokeStyle = colors.separator; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padding, commentBoxY); ctx.lineTo(cardWidth - padding, commentBoxY); ctx.stroke(); // Avatar do usuário const commentAvatarSize = 40; const commentAvatarX = padding; const commentAvatarY = commentBoxY + padding; ctx.save(); ctx.beginPath(); ctx.arc(commentAvatarX + commentAvatarSize / 2, commentAvatarY + commentAvatarSize / 2, commentAvatarSize / 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, commentAvatarX, commentAvatarY, commentAvatarSize, commentAvatarSize); } catch (e) { console.error("Falha ao desenhar avatar de comentário:", e.message); // Avatar de fallback ctx.fillStyle = "#1877F2"; ctx.fillRect(commentAvatarX, commentAvatarY, commentAvatarSize, commentAvatarSize); ctx.fillStyle = "#FFFFFF"; ctx.font = `bold ${commentAvatarSize / 3}px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(this.name.charAt(0).toUpperCase(), commentAvatarX + commentAvatarSize / 2, commentAvatarY + commentAvatarSize / 2); } ctx.restore(); // Caixa de comentário const commentBoxWidth = cardWidth - padding * 2 - commentAvatarSize - 10; const commentBoxX = commentAvatarX + commentAvatarSize + 10; ctx.fillStyle = colors.commentBackground; roundRect(ctx, commentBoxX, commentAvatarY, commentBoxWidth, commentAvatarSize, commentAvatarSize / 2, true, false); ctx.fillStyle = colors.textSecondary; ctx.font = `regular 14px ${registeredFontName}-Regular`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText("Escreva um comentário...", commentBoxX + 15, commentAvatarY + commentAvatarSize / 2); } // --- Codifica e Retorna Buffer --- try { return await encodeToBuffer(canvas); } catch (err) { console.error("Falha ao codificar o Post do Facebook:", err); throw new Error("Não foi possível gerar o buffer de imagem do Post do Facebook."); } } // --- Métodos Auxiliares Privados --- /** * Obtém as cores com base no tema selecionado * @private */ _getThemeColors() { switch (this.theme) { case "dark": return { background: "#242526", text: "#E4E6EB", textSecondary: "#B0B3B8", separator: "#3E4042", reactionBackground: "#3A3B3C", commentBackground: "#3A3B3C" }; case "light": default: return { background: "#FFFFFF", text: "#050505", textSecondary: "#65676B", separator: "#CED0D4", reactionBackground: "#E4E6EB", commentBackground: "#F0F2F5" }; } } };