UNPKG

@cognima/banners

Version:

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

1,014 lines (862 loc) 28.6 kB
"use strict"; /** * Módulo de Processamento de Imagem * * Este módulo fornece funções para processamento avançado de imagens, * incluindo redimensionamento, recorte, composição e outros ajustes. * * @author Cognima Team (melhorado) * @version 2.0.0 */ Object.defineProperty(exports, "__esModule", { value: true }); const pureimage = require("pureimage"); const path = require("path"); const fs = require("fs"); const { loadImageWithAxios, encodeToBuffer } = require("../utils"); const filters = require("./image-filters"); /** * Classe para processamento avançado de imagens */ class ImageProcessor { /** * Cria uma nova instância do processador de imagens * @param {Object} options - Opções de configuração */ constructor(options = {}) { this.canvas = null; this.ctx = null; this.width = options.width || 800; this.height = options.height || 600; this.backgroundColor = options.backgroundColor || "#FFFFFF"; this.quality = options.quality || 0.9; // Inicializa o canvas this._initCanvas(); } /** * Inicializa o canvas com as dimensões especificadas * @private */ _initCanvas() { this.canvas = pureimage.make(this.width, this.height); this.ctx = this.canvas.getContext("2d"); // Preenche o fundo com a cor especificada this.ctx.fillStyle = this.backgroundColor; this.ctx.fillRect(0, 0, this.width, this.height); } /** * Redimensiona o canvas * @param {number} width - Nova largura * @param {number} height - Nova altura * @param {boolean} preserveContent - Se deve preservar o conteúdo atual * @returns {ImageProcessor} - Instância atual para encadeamento */ resize(width, height, preserveContent = true) { if (width === this.width && height === this.height) { return this; // Nenhuma alteração necessária } if (preserveContent) { // Cria um novo canvas com as novas dimensões const newCanvas = pureimage.make(width, height); const newCtx = newCanvas.getContext("2d"); // Preenche o fundo com a cor especificada newCtx.fillStyle = this.backgroundColor; newCtx.fillRect(0, 0, width, height); // Copia o conteúdo do canvas atual para o novo canvas newCtx.drawImage(this.canvas, 0, 0, this.width, this.height, 0, 0, width, height); // Atualiza as referências this.canvas = newCanvas; this.ctx = newCtx; } else { // Simplesmente cria um novo canvas vazio this.width = width; this.height = height; this._initCanvas(); } this.width = width; this.height = height; return this; } /** * Carrega uma imagem no canvas * @param {string|Buffer|Object} source - URL, Buffer ou caminho da imagem * @param {Object} options - Opções de carregamento * @returns {Promise<ImageProcessor>} - Instância atual para encadeamento */ async loadImage(source, options = {}) { const { x = 0, y = 0, width = null, height = null, fit = "contain", // contain, cover, fill, none alignX = "center", // left, center, right alignY = "center", // top, center, bottom offsetX = 0, offsetY = 0 } = options; try { const img = await loadImageWithAxios(source); // Calcula as dimensões de desenho const sourceWidth = img.width; const sourceHeight = img.height; const sourceAspect = sourceWidth / sourceHeight; let drawWidth = width || this.width; let drawHeight = height || this.height; const targetAspect = drawWidth / drawHeight; // Ajusta as dimensões com base no modo de ajuste if (fit === "contain") { if (sourceAspect > targetAspect) { drawHeight = drawWidth / sourceAspect; } else { drawWidth = drawHeight * sourceAspect; } } else if (fit === "cover") { if (sourceAspect > targetAspect) { drawWidth = drawHeight * sourceAspect; } else { drawHeight = drawWidth / sourceAspect; } } else if (fit === "none") { drawWidth = sourceWidth; drawHeight = sourceHeight; } // Para "fill", mantém as dimensões especificadas // Calcula a posição com base no alinhamento let drawX = x; let drawY = y; if (width !== null && fit !== "fill") { if (alignX === "center") { drawX += (width - drawWidth) / 2; } else if (alignX === "right") { drawX += width - drawWidth; } } if (height !== null && fit !== "fill") { if (alignY === "center") { drawY += (height - drawHeight) / 2; } else if (alignY === "bottom") { drawY += height - drawHeight; } } // Aplica os deslocamentos drawX += offsetX; drawY += offsetY; // Desenha a imagem no canvas this.ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight); return this; } catch (err) { console.error("Falha ao carregar imagem:", err.message); throw new Error("Não foi possível carregar a imagem."); } } /** * Recorta o canvas para as dimensões especificadas * @param {number} x - Posição X do recorte * @param {number} y - Posição Y do recorte * @param {number} width - Largura do recorte * @param {number} height - Altura do recorte * @returns {ImageProcessor} - Instância atual para encadeamento */ crop(x, y, width, height) { // Garante que as coordenadas estão dentro dos limites do canvas x = Math.max(0, Math.min(x, this.width)); y = Math.max(0, Math.min(y, this.height)); width = Math.min(width, this.width - x); height = Math.min(height, this.height - y); // Obtém os dados da imagem na área de recorte const imageData = this.ctx.getImageData(x, y, width, height); // Cria um novo canvas com as dimensões do recorte const newCanvas = pureimage.make(width, height); const newCtx = newCanvas.getContext("2d"); // Copia os dados da imagem para o novo canvas newCtx.putImageData(imageData, 0, 0); // Atualiza as referências this.canvas = newCanvas; this.ctx = newCtx; this.width = width; this.height = height; return this; } /** * Rotaciona o canvas * @param {number} angle - Ângulo de rotação em graus * @param {boolean} resize - Se deve redimensionar o canvas para acomodar a imagem rotacionada * @returns {ImageProcessor} - Instância atual para encadeamento */ rotate(angle, resize = true) { // Converte o ângulo para radianos const radians = (angle * Math.PI) / 180; // Calcula as novas dimensões se resize for true let newWidth = this.width; let newHeight = this.height; if (resize) { // Calcula as novas dimensões para acomodar a imagem rotacionada const cos = Math.abs(Math.cos(radians)); const sin = Math.abs(Math.sin(radians)); newWidth = Math.ceil(this.width * cos + this.height * sin); newHeight = Math.ceil(this.width * sin + this.height * cos); } // Cria um novo canvas com as novas dimensões const newCanvas = pureimage.make(newWidth, newHeight); const newCtx = newCanvas.getContext("2d"); // Preenche o fundo com a cor especificada newCtx.fillStyle = this.backgroundColor; newCtx.fillRect(0, 0, newWidth, newHeight); // Translada para o centro do novo canvas newCtx.translate(newWidth / 2, newHeight / 2); // Rotaciona o contexto newCtx.rotate(radians); // Desenha a imagem original centralizada newCtx.drawImage( this.canvas, -this.width / 2, -this.height / 2, this.width, this.height ); // Restaura a transformação newCtx.setTransform(1, 0, 0, 1, 0, 0); // Atualiza as referências this.canvas = newCanvas; this.ctx = newCtx; this.width = newWidth; this.height = newHeight; return this; } /** * Espelha o canvas horizontalmente * @returns {ImageProcessor} - Instância atual para encadeamento */ flipHorizontal() { // Cria um novo canvas com as mesmas dimensões const newCanvas = pureimage.make(this.width, this.height); const newCtx = newCanvas.getContext("2d"); // Espelha horizontalmente newCtx.scale(-1, 1); newCtx.drawImage(this.canvas, -this.width, 0, this.width, this.height); // Restaura a transformação newCtx.setTransform(1, 0, 0, 1, 0, 0); // Atualiza as referências this.canvas = newCanvas; this.ctx = newCtx; return this; } /** * Espelha o canvas verticalmente * @returns {ImageProcessor} - Instância atual para encadeamento */ flipVertical() { // Cria um novo canvas com as mesmas dimensões const newCanvas = pureimage.make(this.width, this.height); const newCtx = newCanvas.getContext("2d"); // Espelha verticalmente newCtx.scale(1, -1); newCtx.drawImage(this.canvas, 0, -this.height, this.width, this.height); // Restaura a transformação newCtx.setTransform(1, 0, 0, 1, 0, 0); // Atualiza as referências this.canvas = newCanvas; this.ctx = newCtx; return this; } /** * Adiciona uma borda ao canvas * @param {number} thickness - Espessura da borda em pixels * @param {string} color - Cor da borda (hexadecimal) * @param {string} style - Estilo da borda ('solid', 'dashed', 'dotted') * @returns {ImageProcessor} - Instância atual para encadeamento */ addBorder(thickness = 5, color = "#000000", style = "solid") { this.ctx.strokeStyle = color; this.ctx.lineWidth = thickness; if (style === "dashed") { this.ctx.setLineDash([thickness * 2, thickness]); } else if (style === "dotted") { this.ctx.setLineDash([thickness, thickness]); } this.ctx.strokeRect( thickness / 2, thickness / 2, this.width - thickness, this.height - thickness ); // Restaura o estilo de linha this.ctx.setLineDash([]); return this; } /** * Adiciona uma marca d'água de texto ao canvas * @param {string} text - Texto da marca d'água * @param {Object} options - Opções da marca d'água * @returns {ImageProcessor} - Instância atual para encadeamento */ addWatermark(text, options = {}) { const { font = "Arial", fontSize = 30, color = "#000000", opacity = 0.3, angle = -30, x = this.width / 2, y = this.height / 2 } = options; filters.applyWatermark( this.ctx, 0, 0, this.width, this.height, text, font, fontSize, color, opacity, angle ); return this; } /** * Adiciona uma marca d'água de imagem ao canvas * @param {string|Buffer|Object} source - URL, Buffer ou caminho da imagem * @param {Object} options - Opções da marca d'água * @returns {Promise<ImageProcessor>} - Instância atual para encadeamento */ async addImageWatermark(source, options = {}) { const { x = this.width / 2, y = this.height / 2, width = this.width * 0.3, height = null, opacity = 0.5, align = "center", // center, topLeft, topRight, bottomLeft, bottomRight offsetX = 0, offsetY = 0 } = options; try { const img = await loadImageWithAxios(source); // Calcula as dimensões proporcionais const imgAspect = img.width / img.height; let drawWidth = width; let drawHeight = height || width / imgAspect; if (!height) { drawHeight = drawWidth / imgAspect; } else { drawWidth = drawHeight * imgAspect; } // Calcula a posição com base no alinhamento let drawX = x; let drawY = y; switch (align) { case "topLeft": drawX = padding + offsetX; drawY = padding + offsetY; break; case "topRight": drawX = this.width - drawWidth - padding + offsetX; drawY = padding + offsetY; break; case "bottomLeft": drawX = padding + offsetX; drawY = this.height - drawHeight - padding + offsetY; break; case "bottomRight": drawX = this.width - drawWidth - padding + offsetX; drawY = this.height - drawHeight - padding + offsetY; break; case "center": default: drawX = (this.width - drawWidth) / 2 + offsetX; drawY = (this.height - drawHeight) / 2 + offsetY; break; } // Salva o estado atual do contexto this.ctx.save(); // Define a opacidade this.ctx.globalAlpha = opacity; // Desenha a imagem this.ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight); // Restaura o estado do contexto this.ctx.restore(); return this; } catch (err) { console.error("Falha ao adicionar marca d'água de imagem:", err.message); throw new Error("Não foi possível adicionar a marca d'água de imagem."); } } /** * Aplica um filtro ao canvas * @param {string} filterName - Nome do filtro a ser aplicado * @param {Object} options - Opções do filtro * @returns {ImageProcessor} - Instância atual para encadeamento */ applyFilter(filterName, options = {}) { // Verifica se o filtro existe if (!filters[filterName]) { throw new Error(`Filtro "${filterName}" não encontrado.`); } // Aplica o filtro com os parâmetros fornecidos filters[filterName](this.ctx, 0, 0, this.width, this.height, ...Object.values(options)); return this; } /** * Aplica múltiplos filtros ao canvas * @param {Array} filtersList - Lista de filtros a serem aplicados * @returns {ImageProcessor} - Instância atual para encadeamento */ applyFilters(filtersList) { for (const filter of filtersList) { this.applyFilter(filter.name, filter.options || {}); } return this; } /** * Adiciona texto ao canvas * @param {string} text - Texto a ser adicionado * @param {Object} options - Opções do texto * @returns {ImageProcessor} - Instância atual para encadeamento */ addText(text, options = {}) { const { x = this.width / 2, y = this.height / 2, font = "Arial", fontSize = 30, color = "#000000", align = "center", // left, center, right baseline = "middle", // top, middle, bottom maxWidth = null, lineHeight = 1.2, shadow = false, shadowColor = "rgba(0, 0, 0, 0.5)", shadowBlur = 3, shadowOffsetX = 1, shadowOffsetY = 1, stroke = false, strokeColor = "#000000", strokeWidth = 1, background = false, backgroundColor = "rgba(255, 255, 255, 0.5)", padding = 5, rotate = 0 } = options; // Salva o estado atual do contexto this.ctx.save(); // Configura o texto this.ctx.font = `${fontSize}px ${font}`; this.ctx.fillStyle = color; this.ctx.textAlign = align; this.ctx.textBaseline = baseline; // Aplica rotação se necessário if (rotate !== 0) { this.ctx.translate(x, y); this.ctx.rotate((rotate * Math.PI) / 180); this.ctx.translate(-x, -y); } // Aplica sombra se necessário if (shadow) { this.ctx.shadowColor = shadowColor; this.ctx.shadowBlur = shadowBlur; this.ctx.shadowOffsetX = shadowOffsetX; this.ctx.shadowOffsetY = shadowOffsetY; } // Se maxWidth for fornecido, quebra o texto em linhas if (maxWidth) { const words = text.split(" "); const lines = []; let currentLine = words[0]; for (let i = 1; i < words.length; i++) { const word = words[i]; const width = this.ctx.measureText(currentLine + " " + word).width; if (width < maxWidth) { currentLine += " " + word; } else { lines.push(currentLine); currentLine = word; } } lines.push(currentLine); // Desenha o fundo se necessário if (background) { let textWidth = 0; for (const line of lines) { const lineWidth = this.ctx.measureText(line).width; textWidth = Math.max(textWidth, lineWidth); } const textHeight = lines.length * fontSize * lineHeight; let bgX, bgY; switch (align) { case "left": bgX = x; break; case "right": bgX = x - textWidth; break; case "center": default: bgX = x - textWidth / 2; break; } switch (baseline) { case "top": bgY = y; break; case "bottom": bgY = y - textHeight; break; case "middle": default: bgY = y - textHeight / 2; break; } this.ctx.fillStyle = backgroundColor; this.ctx.fillRect( bgX - padding, bgY - padding, textWidth + padding * 2, textHeight + padding * 2 ); this.ctx.fillStyle = color; } // Desenha cada linha for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineY = y + (i - (lines.length - 1) / 2) * fontSize * lineHeight; if (stroke) { this.ctx.strokeStyle = strokeColor; this.ctx.lineWidth = strokeWidth; this.ctx.strokeText(line, x, lineY); } this.ctx.fillText(line, x, lineY); } } else { // Desenha o fundo se necessário if (background) { const textWidth = this.ctx.measureText(text).width; const textHeight = fontSize; let bgX, bgY; switch (align) { case "left": bgX = x; break; case "right": bgX = x - textWidth; break; case "center": default: bgX = x - textWidth / 2; break; } switch (baseline) { case "top": bgY = y; break; case "bottom": bgY = y - textHeight; break; case "middle": default: bgY = y - textHeight / 2; break; } this.ctx.fillStyle = backgroundColor; this.ctx.fillRect( bgX - padding, bgY - padding, textWidth + padding * 2, textHeight + padding * 2 ); this.ctx.fillStyle = color; } // Desenha o texto if (stroke) { this.ctx.strokeStyle = strokeColor; this.ctx.lineWidth = strokeWidth; this.ctx.strokeText(text, x, y); } this.ctx.fillText(text, x, y); } // Restaura o estado do contexto this.ctx.restore(); return this; } /** * Adiciona uma forma ao canvas * @param {string} shape - Tipo de forma ('rectangle', 'circle', 'ellipse', 'polygon') * @param {Object} options - Opções da forma * @returns {ImageProcessor} - Instância atual para encadeamento */ addShape(shape, options = {}) { const { x = this.width / 2, y = this.height / 2, width = 100, height = 100, radius = 50, radiusX = 50, radiusY = 30, sides = 6, rotation = 0, fill = true, fillColor = "#000000", stroke = false, strokeColor = "#000000", strokeWidth = 1, opacity = 1 } = options; // Salva o estado atual do contexto this.ctx.save(); // Define a opacidade this.ctx.globalAlpha = opacity; // Configura o estilo de preenchimento e contorno this.ctx.fillStyle = fillColor; this.ctx.strokeStyle = strokeColor; this.ctx.lineWidth = strokeWidth; // Desenha a forma com base no tipo switch (shape) { case "rectangle": if (fill) { this.ctx.fillRect(x - width / 2, y - height / 2, width, height); } if (stroke) { this.ctx.strokeRect(x - width / 2, y - height / 2, width, height); } break; case "circle": this.ctx.beginPath(); this.ctx.arc(x, y, radius, 0, Math.PI * 2); if (fill) { this.ctx.fill(); } if (stroke) { this.ctx.stroke(); } break; case "ellipse": this.ctx.beginPath(); this.ctx.ellipse(x, y, radiusX, radiusY, 0, 0, Math.PI * 2); if (fill) { this.ctx.fill(); } if (stroke) { this.ctx.stroke(); } break; case "polygon": this.ctx.beginPath(); const rotationInRadians = (rotation * Math.PI) / 180; for (let i = 0; i < sides; i++) { const angle = rotationInRadians + (i * 2 * Math.PI) / sides; const pointX = x + radius * Math.cos(angle); const pointY = y + radius * Math.sin(angle); if (i === 0) { this.ctx.moveTo(pointX, pointY); } else { this.ctx.lineTo(pointX, pointY); } } this.ctx.closePath(); if (fill) { this.ctx.fill(); } if (stroke) { this.ctx.stroke(); } break; default: throw new Error(`Forma "${shape}" não suportada.`); } // Restaura o estado do contexto this.ctx.restore(); return this; } /** * Adiciona um gradiente ao canvas * @param {string} type - Tipo de gradiente ('linear', 'radial') * @param {Object} options - Opções do gradiente * @returns {ImageProcessor} - Instância atual para encadeamento */ addGradient(type, options = {}) { const { x1 = 0, y1 = 0, x2 = this.width, y2 = this.height, centerX = this.width / 2, centerY = this.height / 2, radius1 = 0, radius2 = Math.max(this.width, this.height) / 2, colors = ["#000000", "#FFFFFF"], stops = null, opacity = 1 } = options; // Salva o estado atual do contexto this.ctx.save(); // Define a opacidade this.ctx.globalAlpha = opacity; // Cria o gradiente com base no tipo let gradient; if (type === "linear") { gradient = this.ctx.createLinearGradient(x1, y1, x2, y2); } else if (type === "radial") { gradient = this.ctx.createRadialGradient( centerX, centerY, radius1, centerX, centerY, radius2 ); } else { throw new Error(`Tipo de gradiente "${type}" não suportado.`); } // Adiciona as cores ao gradiente if (stops) { // Se os stops forem fornecidos, usa-os for (let i = 0; i < colors.length; i++) { gradient.addColorStop(stops[i], colors[i]); } } else { // Caso contrário, distribui as cores uniformemente for (let i = 0; i < colors.length; i++) { gradient.addColorStop(i / (colors.length - 1), colors[i]); } } // Preenche o canvas com o gradiente this.ctx.fillStyle = gradient; this.ctx.fillRect(0, 0, this.width, this.height); // Restaura o estado do contexto this.ctx.restore(); return this; } /** * Adiciona um padrão ao canvas * @param {string} type - Tipo de padrão ('dots', 'lines', 'grid', 'checkerboard') * @param {Object} options - Opções do padrão * @returns {ImageProcessor} - Instância atual para encadeamento */ addPattern(type, options = {}) { const { color = "#000000", backgroundColor = null, size = 10, spacing = 20, lineWidth = 1, angle = 0, opacity = 0.2 } = options; // Salva o estado atual do contexto this.ctx.save(); // Define a opacidade this.ctx.globalAlpha = opacity; // Preenche o fundo se fornecido if (backgroundColor) { this.ctx.fillStyle = backgroundColor; this.ctx.fillRect(0, 0, this.width, this.height); } // Configura o estilo de desenho this.ctx.fillStyle = color; this.ctx.strokeStyle = color; this.ctx.lineWidth = lineWidth; // Aplica rotação se necessário if (angle !== 0) { this.ctx.translate(this.width / 2, this.height / 2); this.ctx.rotate((angle * Math.PI) / 180); this.ctx.translate(-this.width / 2, -this.height / 2); } // Desenha o padrão com base no tipo switch (type) { case "dots": for (let y = spacing / 2; y < this.height + spacing; y += spacing) { for (let x = spacing / 2; x < this.width + spacing; x += spacing) { this.ctx.beginPath(); this.ctx.arc(x, y, size / 2, 0, Math.PI * 2); this.ctx.fill(); } } break; case "lines": for (let y = 0; y < this.height + spacing; y += spacing) { this.ctx.beginPath(); this.ctx.moveTo(0, y); this.ctx.lineTo(this.width, y); this.ctx.stroke(); } break; case "grid": // Linhas horizontais for (let y = 0; y < this.height + spacing; y += spacing) { this.ctx.beginPath(); this.ctx.moveTo(0, y); this.ctx.lineTo(this.width, y); this.ctx.stroke(); } // Linhas verticais for (let x = 0; x < this.width + spacing; x += spacing) { this.ctx.beginPath(); this.ctx.moveTo(x, 0); this.ctx.lineTo(x, this.height); this.ctx.stroke(); } break; case "checkerboard": const cellSize = spacing; for (let y = 0; y < this.height + cellSize; y += cellSize) { for (let x = 0; x < this.width + cellSize; x += cellSize) { if ((Math.floor(x / cellSize) + Math.floor(y / cellSize)) % 2 === 0) { this.ctx.fillRect(x, y, cellSize, cellSize); } } } break; default: throw new Error(`Tipo de padrão "${type}" não suportado.`); } // Restaura o estado do contexto this.ctx.restore(); return this; } /** * Converte o canvas para um buffer de imagem * @param {string} format - Formato da imagem ('png', 'jpeg') * @param {Object} options - Opções de codificação * @returns {Promise<Buffer>} - Buffer contendo a imagem */ async toBuffer(format = "png", options = {}) { const { quality = this.quality } = options; try { return await encodeToBuffer(this.canvas, format, quality); } catch (err) { console.error("Falha ao codificar a imagem:", err.message); throw new Error("Não foi possível gerar o buffer de imagem."); } } /** * Salva o canvas em um arquivo * @param {string} filePath - Caminho do arquivo * @param {string} format - Formato da imagem ('png', 'jpeg') * @param {Object} options - Opções de codificação * @returns {Promise<string>} - Caminho do arquivo salvo */ async saveToFile(filePath, format = null, options = {}) { // Determina o formato com base na extensão do arquivo se não for fornecido if (!format) { const ext = path.extname(filePath).toLowerCase(); format = ext === ".jpg" || ext === ".jpeg" ? "jpeg" : "png"; } try { const buffer = await this.toBuffer(format, options); // Cria o diretório se não existir const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // Salva o buffer no arquivo fs.writeFileSync(filePath, buffer); return filePath; } catch (err) { console.error("Falha ao salvar a imagem:", err.message); throw new Error("Não foi possível salvar a imagem no arquivo."); } } } module.exports = ImageProcessor;