UNPKG

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