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