@cognima/banners
Version:
Biblioteca avançada para geração de banners dinâmicos para diversas plataformas e aplicações
659 lines (555 loc) • 20.9 kB
JavaScript
"use strict";
/**
* Módulo de Banner de Post do LinkedIn
*
* Este módulo gera banners no estilo de posts do LinkedIn 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 LinkedInPost
* @classdesc Gera um banner no estilo de post do LinkedIn.
* @example const post = new LinkedInPost()
* .setName("Nome Completo")
* .setTitle("Cargo | Empresa")
* .setContent("Conteúdo do post com #hashtags")
* .setImage("imagem.jpg")
* .setLikes(500)
* .setComments(50)
* .setShares(20)
* .build();
*/
module.exports = class LinkedInPost {
constructor(options) {
// Dados Principais
this.name = "Nome Completo";
this.title = "Cargo | Empresa";
this.avatar = null;
this.content = "Conteúdo do post com #hashtags";
this.image = null;
this.likes = 0;
this.comments = 0;
this.shares = 0;
this.postTime = "1h";
this.isPremium = false;
this.isCompanyPost = false;
this.companyLogo = null;
this.companyName = null;
this.hashtags = [];
this.pollOptions = null;
this.pollVotes = null;
this.pollTimeLeft = null;
// 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, article, poll, document, event
this.cornerRadius = LAYOUT.cornerRadius.small;
// Configurações de Layout
this.cardWidth = DEFAULT_DIMENSIONS.post.width;
this.cardHeight = 800;
}
// --- Setters para Dados Principais ---
/**
* Define o nome completo
* @param {string} text - Nome completo
* @returns {LinkedInPost} - 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 {LinkedInPost} - 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 o avatar
* @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar
* @returns {LinkedInPost} - 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 {LinkedInPost} - 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;
// Extrai hashtags automaticamente
const hashtagRegex = /#(\w+)/g;
const matches = text.match(hashtagRegex);
if (matches) {
this.hashtags = matches.map(tag => tag.substring(1)); // Remove o # do início
}
return this;
}
/**
* Define a imagem principal do post
* @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem
* @returns {LinkedInPost} - 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 {LinkedInPost} - 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 {LinkedInPost} - 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 {LinkedInPost} - 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 {LinkedInPost} - 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 é premium
* @param {boolean} isPremium - Se o usuário é premium
* @returns {LinkedInPost} - Instância atual para encadeamento
*/
setPremium(isPremium = true) {
this.isPremium = !!isPremium;
return this;
}
/**
* Define se é um post de empresa
* @param {boolean} isCompanyPost - Se é um post de empresa
* @param {string} companyName - Nome da empresa
* @param {string|Buffer|Object} companyLogo - URL, Buffer ou caminho do logo da empresa
* @returns {LinkedInPost} - Instância atual para encadeamento
*/
setCompanyPost(isCompanyPost = true, companyName = null, companyLogo = null) {
this.isCompanyPost = !!isCompanyPost;
if (isCompanyPost) {
if (!companyName || typeof companyName !== "string") {
throw new Error("O nome da empresa deve ser uma string não vazia.");
}
this.companyName = companyName;
this.companyLogo = companyLogo;
}
return this;
}
/**
* Define as opções de enquete
* @param {Array<string>} options - Array de opções da enquete
* @param {Array<number>} votes - Array de votos para cada opção
* @param {string} timeLeft - Tempo restante da enquete (ex: "2 dias restantes")
* @returns {LinkedInPost} - Instância atual para encadeamento
*/
setPoll(options, votes = null, timeLeft = null) {
if (!Array.isArray(options) || options.length < 2) {
throw new Error("As opções da enquete devem ser um array com pelo menos 2 itens.");
}
this.pollOptions = options;
this.pollVotes = votes;
this.pollTimeLeft = timeLeft;
this.postType = "poll";
return this;
}
// --- Setters para Personalização Visual ---
/**
* Define o tema
* @param {string} theme - Tema ('light', 'dark')
* @returns {LinkedInPost} - 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', 'article', 'poll', 'document', 'event')
* @returns {LinkedInPost} - Instância atual para encadeamento
*/
setPostType(type) {
const validTypes = ["standard", "article", "poll", "document", "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 {LinkedInPost} - 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 {LinkedInPost} - 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 = 20;
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 = 80;
// Avatar
const avatarSize = 60;
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 = "#0A66C2";
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 + 15;
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 premium (se aplicável)
if (this.isPremium) {
const premiumSize = 16;
const premiumX = infoX + nameWidth + 5;
ctx.fillStyle = "#0A66C2";
ctx.beginPath();
ctx.arc(premiumX + premiumSize / 2, infoY + premiumSize / 2, premiumSize / 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#FFFFFF";
ctx.font = `bold 12px ${registeredFontName}-Bold`;
ctx.textAlign = "center";
ctx.fillText("in", premiumX + premiumSize / 2, infoY + premiumSize / 2);
}
// Título
infoY += 20;
ctx.fillStyle = colors.textSecondary;
ctx.font = `regular 14px ${registeredFontName}-Regular`;
ctx.textAlign = "left";
ctx.fillText(this.title, infoX, infoY);
// Tempo de publicação
infoY += 20;
ctx.fillText(this.postTime, infoX, infoY);
// Ícone de público
const publicIconX = infoX + ctx.measureText(this.postTime).width + 10;
ctx.fillText("• 🌎", publicIconX, infoY);
// Botão de seguir
const followButtonWidth = 80;
const followButtonHeight = 30;
const followButtonX = cardWidth - followButtonWidth - padding;
const followButtonY = padding + 15;
ctx.strokeStyle = "#0A66C2";
ctx.lineWidth = 1;
roundRect(ctx, followButtonX, followButtonY, followButtonWidth, followButtonHeight, followButtonHeight / 2, false, true);
ctx.fillStyle = "#0A66C2";
ctx.font = `bold 14px ${registeredFontName}-Bold`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("+ Seguir", followButtonX + followButtonWidth / 2, followButtonY + followButtonHeight / 2);
// Botão de mais opções
const moreButtonX = followButtonX - 40;
const moreButtonY = followButtonY + followButtonHeight / 2;
ctx.fillStyle = colors.textSecondary;
ctx.textAlign = "center";
ctx.fillText("•••", moreButtonX, moreButtonY);
// --- Desenha Conteúdo do Post ---
let contentY = headerHeight + padding;
// Texto do post
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 && this.postType !== "poll") {
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);
}
}
// Enquete (se aplicável)
if (this.postType === "poll" && this.pollOptions) {
const pollY = contentY;
const pollWidth = cardWidth - padding * 2;
const optionHeight = 50;
const pollPadding = 15;
const pollHeight = this.pollOptions.length * (optionHeight + pollPadding) + pollPadding;
// Fundo da enquete
ctx.fillStyle = colors.cardBackground;
roundRect(ctx, padding, pollY, pollWidth, pollHeight, cornerRadius, true, false);
// Opções da enquete
let optionY = pollY + pollPadding;
const totalVotes = this.pollVotes ? this.pollVotes.reduce((a, b) => a + b, 0) : 0;
this.pollOptions.forEach((option, index) => {
const votePercentage = this.pollVotes && totalVotes > 0 ? (this.pollVotes[index] / totalVotes) * 100 : 0;
// Barra de progresso
ctx.fillStyle = colors.pollBackground;
roundRect(ctx, padding + pollPadding, optionY, pollWidth - pollPadding * 2, optionHeight, optionHeight / 2, true, false);
if (votePercentage > 0) {
ctx.fillStyle = "#0A66C2";
roundRect(
ctx,
padding + pollPadding,
optionY,
(pollWidth - pollPadding * 2) * (votePercentage / 100),
optionHeight,
optionHeight / 2,
true,
false
);
}
// Texto da opção
ctx.fillStyle = colors.text;
ctx.font = `regular 16px ${registeredFontName}-Regular`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(option, padding + pollPadding * 2, optionY + optionHeight / 2);
// Porcentagem (se aplicável)
if (this.pollVotes) {
ctx.fillStyle = colors.textSecondary;
ctx.font = `regular 14px ${registeredFontName}-Regular`;
ctx.textAlign = "right";
ctx.fillText(
`${Math.round(votePercentage)}%`,
cardWidth - padding - pollPadding * 2,
optionY + optionHeight / 2
);
}
optionY += optionHeight + pollPadding;
});
// Tempo restante (se fornecido)
if (this.pollTimeLeft) {
ctx.fillStyle = colors.textSecondary;
ctx.font = `regular 14px ${registeredFontName}-Regular`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText(this.pollTimeLeft, padding + pollPadding, optionY);
}
contentY = pollY + pollHeight + padding;
}
// Tipo de post específico (se aplicável)
if (this.postType === "article" || this.postType === "document" || this.postType === "event") {
const typeY = contentY;
const typeWidth = cardWidth - padding * 2;
const typeHeight = 30;
ctx.fillStyle = colors.cardBackground;
roundRect(ctx, padding, typeY, typeWidth, typeHeight, cornerRadius, true, false);
ctx.fillStyle = colors.textSecondary;
ctx.font = `regular 14px ${registeredFontName}-Regular`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let typeText = "";
switch (this.postType) {
case "article":
typeText = "Artigo";
break;
case "document":
typeText = "Documento";
break;
case "event":
typeText = "Evento";
break;
}
ctx.fillText(typeText, padding + typeWidth / 2, typeY + typeHeight / 2);
contentY = typeY + typeHeight + padding;
}
// --- Desenha Barra de Interações ---
const interactionBarY = Math.max(contentY, cardHeight - 80);
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) / 4;
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(`👍 ${formatNumber(this.likes)}`, padding + iconSpacing / 2, iconY);
// Ícone de comentário
ctx.fillText(`💬 ${formatNumber(this.comments)}`, padding + iconSpacing * 1.5, iconY);
// Ícone de compartilhamento
ctx.fillText(`↗ ${formatNumber(this.shares)}`, padding + iconSpacing * 2.5, iconY);
// Ícone de envio
ctx.fillText("✉", padding + iconSpacing * 3.5, iconY);
// --- Codifica e Retorna Buffer ---
try {
return await encodeToBuffer(canvas);
} catch (err) {
console.error("Falha ao codificar o Post do LinkedIn:", err);
throw new Error("Não foi possível gerar o buffer de imagem do Post do LinkedIn.");
}
}
// --- Métodos Auxiliares Privados ---
/**
* Obtém as cores com base no tema selecionado
* @private
*/
_getThemeColors() {
switch (this.theme) {
case "dark":
return {
background: "#1C1C1C",
cardBackground: "#2D2D2D",
pollBackground: "#3D3D3D",
text: "#FFFFFF",
textSecondary: "#B3B3B3",
separator: "#3D3D3D"
};
case "light":
default:
return {
background: "#FFFFFF",
cardBackground: "#F3F2EF",
pollBackground: "#E9E5DF",
text: "#000000",
textSecondary: "#666666",
separator: "#E9E5DF"
};
}
}
};