@cognima/banners 0.0.1-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/assets/fonts/Manrope/Manrope-Bold.ttf +0 -0
  2. package/assets/fonts/Manrope/Manrope-Regular.ttf +0 -0
  3. package/assets/fonts/Others/AbyssinicaSIL-Regular.ttf +0 -0
  4. package/assets/fonts/Others/ChirpRegular.ttf +0 -0
  5. package/assets/fonts/Poppins/Poppins-Bold.ttf +0 -0
  6. package/assets/fonts/Poppins/Poppins-Medium.ttf +0 -0
  7. package/assets/fonts/Poppins/Poppins-Regular.ttf +0 -0
  8. package/assets/placeholders/album_art.png +0 -0
  9. package/assets/placeholders/avatar.png +0 -0
  10. package/assets/placeholders/badge.jpg +0 -0
  11. package/assets/placeholders/badge.png +0 -0
  12. package/assets/placeholders/badge_2.jpg +0 -0
  13. package/assets/placeholders/badge_3.jpg +0 -0
  14. package/assets/placeholders/badge_4.jpg +0 -0
  15. package/assets/placeholders/badge_5.jpg +0 -0
  16. package/assets/placeholders/banner.jpeg +0 -0
  17. package/assets/placeholders/images.jpeg +0 -0
  18. package/index.js +153 -0
  19. package/package.json +34 -0
  20. package/src/animation-effects.js +631 -0
  21. package/src/cache-manager.js +258 -0
  22. package/src/community-banner.js +1536 -0
  23. package/src/constants.js +208 -0
  24. package/src/discord-profile.js +584 -0
  25. package/src/e-commerce-banner.js +1214 -0
  26. package/src/effects.js +355 -0
  27. package/src/error-handler.js +305 -0
  28. package/src/event-banner.js +1319 -0
  29. package/src/facebook-post.js +679 -0
  30. package/src/gradient-welcome.js +430 -0
  31. package/src/image-filters.js +1034 -0
  32. package/src/image-processor.js +1014 -0
  33. package/src/instagram-post.js +504 -0
  34. package/src/interactive-elements.js +1208 -0
  35. package/src/linkedin-post.js +658 -0
  36. package/src/marketing-banner.js +1089 -0
  37. package/src/minimalist-banner.js +892 -0
  38. package/src/modern-profile.js +755 -0
  39. package/src/performance-optimizer.js +216 -0
  40. package/src/telegram-header.js +544 -0
  41. package/src/test-runner.js +645 -0
  42. package/src/tiktok-post.js +713 -0
  43. package/src/twitter-header.js +604 -0
  44. package/src/validator.js +442 -0
  45. package/src/welcome-leave.js +445 -0
  46. package/src/whatsapp-status.js +386 -0
  47. package/src/youtube-thumbnail.js +681 -0
  48. package/utils.js +710 -0
package/utils.js ADDED
@@ -0,0 +1,710 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Módulo de Utilitários para @cognima/banners
5
+ *
6
+ * Este módulo contém funções auxiliares para manipulação de imagens, fontes,
7
+ * desenho em canvas e formatação de dados utilizados pelos geradores de banners.
8
+ *
9
+ * @author Cognima Team (melhorado)
10
+ * @version 2.0.0
11
+ */
12
+
13
+ const pureimage = require("pureimage");
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const axios = require("axios");
17
+ const { Readable } = require("stream");
18
+
19
+ // --- Constantes para Padronização ---
20
+ const DEFAULT_FONT_FAMILY = "Poppins";
21
+ const DEFAULT_FONT_DIR = path.join(__dirname, "/assets/fonts/Poppins");
22
+ const FONT_WEIGHTS = {
23
+ bold: "Poppins-Bold.ttf",
24
+ medium: "Poppins-Medium.ttf",
25
+ regular: "Poppins-Regular.ttf",
26
+ };
27
+
28
+ // --- Validação de Cores ---
29
+ /**
30
+ * Verifica se uma string é uma cor hexadecimal válida
31
+ * @param {string} color - Cor em formato hexadecimal (#RRGGBB ou #RGB)
32
+ * @returns {boolean} - Verdadeiro se for uma cor hexadecimal válida
33
+ */
34
+ function isValidHexColor(color) {
35
+ return /^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(color);
36
+ }
37
+
38
+ // --- Registro de Fontes ---
39
+ let fontsRegistered = false;
40
+ const registeredFontNames = new Set(); // Controla nomes de fontes registradas com sucesso
41
+
42
+ /**
43
+ * Registra as fontes padrão do sistema
44
+ * @returns {Promise<string>} - Nome da família de fonte registrada
45
+ */
46
+ async function registerDefaultFonts() {
47
+ if (fontsRegistered) {
48
+ return DEFAULT_FONT_FAMILY;
49
+ }
50
+
51
+ try {
52
+ const fontPromises = [];
53
+
54
+ // Registra cada peso de fonte
55
+ for (const weight in FONT_WEIGHTS) {
56
+ const fontPath = path.join(DEFAULT_FONT_DIR, FONT_WEIGHTS[weight]);
57
+
58
+ if (fs.existsSync(fontPath)) {
59
+ const fontName = `${DEFAULT_FONT_FAMILY}-${weight.charAt(0).toUpperCase() + weight.slice(1)}`;
60
+ const font = pureimage.registerFont(fontPath, fontName);
61
+
62
+ fontPromises.push(
63
+ font.load().then(() => {
64
+ registeredFontNames.add(fontName);
65
+ }).catch(err => {
66
+ console.error(`Falha ao carregar fonte ${fontName}:`, err);
67
+ throw err;
68
+ })
69
+ );
70
+ } else {
71
+ console.warn(`Arquivo de fonte padrão não encontrado: ${fontPath}`);
72
+ }
73
+ }
74
+
75
+ if (fontPromises.length > 0) {
76
+ await Promise.all(fontPromises);
77
+
78
+ if (registeredFontNames.size === Object.keys(FONT_WEIGHTS).length) {
79
+ fontsRegistered = true;
80
+ return DEFAULT_FONT_FAMILY;
81
+ } else {
82
+ throw new Error(`Apenas ${registeredFontNames.size}/${Object.keys(FONT_WEIGHTS).length} fontes padrão foram registradas com sucesso.`);
83
+ }
84
+ } else {
85
+ throw new Error("Nenhum arquivo de fonte padrão encontrado no diretório esperado.");
86
+ }
87
+ } catch (err) {
88
+ console.error("Erro ao registrar fontes padrão:", err);
89
+ throw new Error("Falha ao carregar fontes padrão. Certifique-se de que os arquivos da fonte Poppins (Bold, Medium, Regular) estão em assets/fonts/Poppins.");
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Registra uma fonte personalizada se necessário
95
+ * @param {Object} fontConfig - Configuração da fonte (nome e caminho)
96
+ * @returns {Promise<string>} - Nome da família de fonte registrada
97
+ */
98
+ async function registerFontIfNeeded(fontConfig) {
99
+ if (!fontConfig || !fontConfig.path || !fontConfig.name) {
100
+ // Usa fontes padrão se nenhuma fonte personalizada for fornecida ou a configuração estiver incompleta
101
+ return await registerDefaultFonts();
102
+ }
103
+
104
+ try {
105
+ if (fs.existsSync(fontConfig.path)) {
106
+ const font = pureimage.registerFont(fontConfig.path, fontConfig.name);
107
+ await font.load();
108
+ registeredFontNames.add(fontConfig.name);
109
+ return fontConfig.name; // Retorna o nome da fonte personalizada registrada
110
+ } else {
111
+ console.warn(`Arquivo de fonte personalizada não encontrado: ${fontConfig.path}. Usando fontes padrão.`);
112
+ return await registerDefaultFonts();
113
+ }
114
+ } catch (err) {
115
+ console.error(`Erro ao registrar fonte personalizada ${fontConfig.name}:`, err);
116
+ console.warn("Usando fontes padrão como alternativa.");
117
+ return await registerDefaultFonts();
118
+ }
119
+ }
120
+
121
+ // --- Carregamento de Imagens ---
122
+ /**
123
+ * Carrega uma imagem a partir de várias fontes possíveis (URL, Buffer, caminho local)
124
+ * @param {string|Buffer|Object} source - Fonte da imagem (URL, Buffer ou caminho local)
125
+ * @returns {Promise<Object>} - Objeto bitmap da imagem carregada
126
+ */
127
+ async function loadImageWithAxios(source) {
128
+ if (!source) throw new Error("A fonte da imagem não pode ser nula ou vazia.");
129
+
130
+ // Carrega imagem a partir de URL
131
+ if (typeof source === "string" && source.startsWith("http")) {
132
+ try {
133
+ const response = await axios.get(source, { responseType: "arraybuffer" });
134
+ const buffer = Buffer.from(response.data, "binary");
135
+ const contentType = response.headers["content-type"];
136
+
137
+ // Tenta decodificar com base no tipo de conteúdo
138
+ if (contentType && contentType.includes("png")) {
139
+ return await pureimage.decodePNGFromStream(Readable.from(buffer));
140
+ } else if (contentType && (contentType.includes("jpeg") || contentType.includes("jpg"))) {
141
+ return await pureimage.decodeJPEGFromStream(Readable.from(buffer));
142
+ } else {
143
+ // Tenta decodificar, priorizando PNG e depois JPEG
144
+ try {
145
+ return await pureimage.decodePNGFromStream(Readable.from(buffer));
146
+ } catch (e) { /* ignora */ }
147
+
148
+ try {
149
+ return await pureimage.decodeJPEGFromStream(Readable.from(buffer));
150
+ } catch (e) { /* ignora */ }
151
+
152
+ throw new Error(`Tipo de imagem não suportado '${contentType}' da URL: ${source}`);
153
+ }
154
+ } catch (error) {
155
+ console.error(`Erro ao carregar imagem da URL ${source}:`, error.message);
156
+ throw new Error(`Falha ao carregar imagem da URL: ${source}. Verifique se a URL está correta e o formato da imagem é PNG ou JPEG.`);
157
+ }
158
+ }
159
+ // Carrega imagem a partir de Buffer
160
+ else if (Buffer.isBuffer(source)) {
161
+ try {
162
+ return await pureimage.decodePNGFromStream(Readable.from(source));
163
+ } catch (e) { /* ignora */ }
164
+
165
+ try {
166
+ return await pureimage.decodeJPEGFromStream(Readable.from(source));
167
+ } catch (e) { /* ignora */ }
168
+
169
+ throw new Error("Falha ao decodificar imagem do buffer. Certifique-se de que é um buffer PNG ou JPEG válido.");
170
+ }
171
+ // Carrega imagem a partir de caminho local
172
+ else if (typeof source === "string") {
173
+ try {
174
+ const stream = fs.createReadStream(source);
175
+
176
+ if (source.toLowerCase().endsWith(".png")) {
177
+ return await pureimage.decodePNGFromStream(stream);
178
+ }
179
+
180
+ if (source.toLowerCase().endsWith(".jpg") || source.toLowerCase().endsWith(".jpeg")) {
181
+ return await pureimage.decodeJPEGFromStream(stream);
182
+ }
183
+
184
+ throw new Error("Tipo de arquivo de imagem local não suportado. Apenas PNG e JPEG são suportados.");
185
+ } catch (error) {
186
+ console.error(`Erro ao carregar imagem do caminho ${source}:`, error.message);
187
+ throw new Error(`Falha ao carregar imagem do caminho: ${source}. Verifique se o caminho está correto e o arquivo é PNG ou JPEG.`);
188
+ }
189
+ }
190
+ // Já é um bitmap
191
+ else if (source && source.width && source.height && source.data) {
192
+ return source;
193
+ }
194
+
195
+ throw new Error("Fonte de imagem inválida fornecida. Deve ser uma URL (http/https), Buffer, caminho local ou um bitmap pureimage.");
196
+ }
197
+
198
+ // --- Codificação de Canvas ---
199
+ /**
200
+ * Codifica um canvas para um buffer PNG
201
+ * @param {Object} canvas - Objeto canvas do pureimage
202
+ * @returns {Promise<Buffer>} - Buffer contendo a imagem PNG
203
+ */
204
+ async function encodeToBuffer(canvas) {
205
+ const writableStream = new (require("stream").Writable)({
206
+ write(chunk, encoding, callback) {
207
+ if (!this.data) this.data = [];
208
+ this.data.push(chunk);
209
+ callback();
210
+ },
211
+ });
212
+
213
+ await pureimage.encodePNGToStream(canvas, writableStream);
214
+
215
+ if (!writableStream.data) throw new Error("Falha ao codificar imagem para buffer.");
216
+
217
+ return Buffer.concat(writableStream.data);
218
+ }
219
+
220
+ // --- Auxiliares de Desenho ---
221
+ /**
222
+ * Desenha um retângulo com cantos arredondados
223
+ * @param {Object} ctx - Contexto de renderização 2D
224
+ * @param {number} x - Posição X do retângulo
225
+ * @param {number} y - Posição Y do retângulo
226
+ * @param {number} width - Largura do retângulo
227
+ * @param {number} height - Altura do retângulo
228
+ * @param {number|Object} radius - Raio dos cantos (número ou objeto com raios específicos)
229
+ * @param {boolean} fill - Se deve preencher o retângulo
230
+ * @param {boolean} stroke - Se deve desenhar o contorno do retângulo
231
+ */
232
+ function roundRect(ctx, x, y, width, height, radius, fill, stroke) {
233
+ // Normaliza o raio para um objeto com todos os cantos
234
+ if (typeof radius === "number") {
235
+ radius = { tl: radius, tr: radius, br: radius, bl: radius };
236
+ } else {
237
+ radius = { ...{ tl: 0, tr: 0, br: 0, bl: 0 }, ...radius };
238
+ }
239
+
240
+ // Desenha o caminho do retângulo arredondado
241
+ ctx.beginPath();
242
+ ctx.moveTo(x + radius.tl, y);
243
+ ctx.lineTo(x + width - radius.tr, y);
244
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr);
245
+ ctx.lineTo(x + width, y + height - radius.br);
246
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height);
247
+ ctx.lineTo(x + radius.bl, y + height);
248
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl);
249
+ ctx.lineTo(x, y + radius.tl);
250
+ ctx.quadraticCurveTo(x, y, x + radius.tl, y);
251
+ ctx.closePath();
252
+
253
+ // Aplica preenchimento e/ou contorno conforme solicitado
254
+ if (fill) {
255
+ ctx.fill();
256
+ }
257
+ if (stroke) {
258
+ ctx.stroke();
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Quebra texto em múltiplas linhas para caber em uma largura máxima
264
+ * @param {Object} context - Contexto de renderização 2D
265
+ * @param {string} text - Texto a ser quebrado
266
+ * @param {number} x - Posição X inicial do texto
267
+ * @param {number} y - Posição Y inicial do texto
268
+ * @param {number} maxWidth - Largura máxima para o texto
269
+ * @param {number} lineHeight - Altura da linha
270
+ * @param {string} baseFontName - Nome base da fonte
271
+ * @returns {number} - Posição Y final após desenhar o texto
272
+ */
273
+ function wrapText(context, text, x, y, maxWidth, lineHeight, baseFontName) {
274
+ const words = text.split(" ");
275
+ let line = "";
276
+ let currentY = y;
277
+
278
+ // Extrai informações da fonte atual
279
+ const initialFont = context.font || `16px ${baseFontName}-Regular`;
280
+ const fontMatch = initialFont.match(/\d+px/);
281
+ const fontSize = fontMatch ? fontMatch[0] : "16px";
282
+ const fontWeight = initialFont.includes("Bold") ? "Bold" : initialFont.includes("Medium") ? "Medium" : "Regular";
283
+ const fontName = `${baseFontName}-${fontWeight}`;
284
+
285
+ // Verifica se a fonte está registrada
286
+ if (!registeredFontNames.has(fontName)) {
287
+ context.font = `${fontSize} ${baseFontName}-Regular`;
288
+ } else {
289
+ context.font = `${fontSize} ${fontName}`;
290
+ }
291
+
292
+ // Processa cada palavra e quebra em linhas
293
+ for (let n = 0; n < words.length; n++) {
294
+ const testLine = line + words[n] + " ";
295
+ context.font = `${fontSize} ${fontName}`;
296
+ const metrics = context.measureText(testLine);
297
+ const testWidth = metrics.width;
298
+
299
+ if (testWidth > maxWidth && n > 0) {
300
+ context.fillText(line.trim(), x, currentY);
301
+ line = words[n] + " ";
302
+ currentY += lineHeight;
303
+ } else {
304
+ line = testLine;
305
+ }
306
+ }
307
+
308
+ // Desenha a última linha
309
+ context.font = `${fontSize} ${fontName}`;
310
+ context.fillText(line.trim(), x, currentY);
311
+
312
+ return currentY + lineHeight; // Retorna a posição Y para a próxima linha
313
+ }
314
+
315
+ // --- Auxiliares de Formatação ---
316
+ /**
317
+ * Formata um tempo em milissegundos para o formato MM:SS
318
+ * @param {number} ms - Tempo em milissegundos
319
+ * @returns {string} - Tempo formatado (MM:SS)
320
+ */
321
+ function formatTime(ms) {
322
+ if (typeof ms !== "number" || isNaN(ms) || ms < 0) return "0:00";
323
+
324
+ const totalSeconds = Math.floor(ms / 1000);
325
+ const minutes = Math.floor(totalSeconds / 60);
326
+ const seconds = totalSeconds % 60;
327
+
328
+ return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
329
+ }
330
+
331
+ /**
332
+ * Formata um número para exibição amigável (com K, M para milhares e milhões)
333
+ * @param {number} num - Número a ser formatado
334
+ * @returns {string} - Número formatado
335
+ */
336
+ function formatNumber(num) {
337
+ if (typeof num !== "number" || isNaN(num)) return "0";
338
+
339
+ // Formatação simples K/M para números grandes
340
+ if (num >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
341
+ if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, "") + "K";
342
+
343
+ return num.toString();
344
+ }
345
+
346
+ /**
347
+ * Aplica um efeito de sombra de texto padronizado
348
+ * @param {Object} ctx - Contexto de renderização 2D
349
+ * @param {string} color - Cor da sombra (hexadecimal)
350
+ * @param {number} blur - Desfoque da sombra
351
+ * @param {number} offsetX - Deslocamento X da sombra
352
+ * @param {number} offsetY - Deslocamento Y da sombra
353
+ */
354
+ function applyTextShadow(ctx, color = "#000000", blur = 3, offsetX = 1, offsetY = 1) {
355
+ ctx.shadowColor = color;
356
+ ctx.shadowBlur = blur;
357
+ ctx.shadowOffsetX = offsetX;
358
+ ctx.shadowOffsetY = offsetY;
359
+ }
360
+
361
+ /**
362
+ * Remove efeitos de sombra do contexto
363
+ * @param {Object} ctx - Contexto de renderização 2D
364
+ */
365
+ function clearShadow(ctx) {
366
+ ctx.shadowColor = "transparent";
367
+ ctx.shadowBlur = 0;
368
+ ctx.shadowOffsetX = 0;
369
+ ctx.shadowOffsetY = 0;
370
+ }
371
+
372
+ /**
373
+ * Aplica um efeito de gradiente linear ao contexto
374
+ * @param {Object} ctx - Contexto de renderização 2D
375
+ * @param {number} x - Posição X inicial do gradiente
376
+ * @param {number} y - Posição Y inicial do gradiente
377
+ * @param {number} width - Largura do gradiente
378
+ * @param {number} height - Altura do gradiente
379
+ * @param {string} startColor - Cor inicial do gradiente (hexadecimal)
380
+ * @param {string} endColor - Cor final do gradiente (hexadecimal)
381
+ * @param {string} direction - Direção do gradiente ('horizontal', 'vertical', 'diagonal')
382
+ * @returns {Object} - Objeto de gradiente criado
383
+ */
384
+ function createLinearGradient(ctx, x, y, width, height, startColor, endColor, direction = 'vertical') {
385
+ let gradient;
386
+
387
+ switch (direction.toLowerCase()) {
388
+ case 'horizontal':
389
+ gradient = ctx.createLinearGradient(x, y, x + width, y);
390
+ break;
391
+ case 'diagonal':
392
+ gradient = ctx.createLinearGradient(x, y, x + width, y + height);
393
+ break;
394
+ case 'vertical':
395
+ default:
396
+ gradient = ctx.createLinearGradient(x, y, x, y + height);
397
+ break;
398
+ }
399
+
400
+ gradient.addColorStop(0, startColor);
401
+ gradient.addColorStop(1, endColor);
402
+
403
+ return gradient;
404
+ }
405
+
406
+ /**
407
+ * Converte uma cor hexadecimal para RGBA
408
+ * @param {string} hex - Cor em formato hexadecimal
409
+ * @param {number} alpha - Valor de transparência (0-1)
410
+ * @returns {string} - Cor em formato RGBA
411
+ */
412
+ function hexToRgba(hex, alpha = 1) {
413
+ if (!isValidHexColor(hex)) {
414
+ return `rgba(0, 0, 0, ${alpha})`;
415
+ }
416
+
417
+ // Expande formato abreviado (#RGB para #RRGGBB)
418
+ let r, g, b;
419
+ if (hex.length === 4) {
420
+ r = parseInt(hex[1] + hex[1], 16);
421
+ g = parseInt(hex[2] + hex[2], 16);
422
+ b = parseInt(hex[3] + hex[3], 16);
423
+ } else {
424
+ r = parseInt(hex.slice(1, 3), 16);
425
+ g = parseInt(hex.slice(3, 5), 16);
426
+ b = parseInt(hex.slice(5, 7), 16);
427
+ }
428
+
429
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
430
+ }
431
+
432
+ // --- Funções Adicionais de Utilidade ---
433
+
434
+ /**
435
+ * Alias para loadImageWithAxios para compatibilidade
436
+ * @param {string|Buffer|Object} source - Fonte da imagem
437
+ * @returns {Promise<Object>} - Objeto bitmap da imagem carregada
438
+ */
439
+ async function loadImage(source) {
440
+ return loadImageWithAxios(source);
441
+ }
442
+
443
+ /**
444
+ * Alias para encodeToBuffer para compatibilidade
445
+ * @param {Object} canvas - Objeto canvas do pureimage
446
+ * @returns {Promise<Buffer>} - Buffer contendo a imagem PNG
447
+ */
448
+ async function getBufferFromImage(canvas) {
449
+ return encodeToBuffer(canvas);
450
+ }
451
+
452
+ /**
453
+ * Desenha uma imagem mantendo proporção e preenchendo a área especificada
454
+ * @param {Object} ctx - Contexto de renderização 2D
455
+ * @param {Object} img - Imagem a ser desenhada
456
+ * @param {number} x - Posição X
457
+ * @param {number} y - Posição Y
458
+ * @param {number} w - Largura da área
459
+ * @param {number} h - Altura da área
460
+ * @param {number} offsetX - Deslocamento X (0-1, padrão 0.5 = centro)
461
+ * @param {number} offsetY - Deslocamento Y (0-1, padrão 0.5 = centro)
462
+ */
463
+ function drawImageProp(ctx, img, x, y, w, h, offsetX = 0.5, offsetY = 0.5) {
464
+ if (!img || !img.width || !img.height) return;
465
+
466
+ const iw = img.width;
467
+ const ih = img.height;
468
+ const r = Math.min(w / iw, h / ih);
469
+ let nw = iw * r;
470
+ let nh = ih * r;
471
+ let cx, cy, cw, ch, ar = 1;
472
+
473
+ // Decide qual dimensão ajustar
474
+ if (nw < w) ar = w / nw;
475
+ if (Math.abs(ar - 1) < 1e-14 && nh < h) ar = h / nh;
476
+ nw *= ar;
477
+ nh *= ar;
478
+
479
+ // Calcula a posição de recorte
480
+ cw = iw / (nw / w);
481
+ ch = ih / (nh / h);
482
+ cx = (iw - cw) * offsetX;
483
+ cy = (ih - ch) * offsetY;
484
+
485
+ // Garante que os valores não sejam negativos
486
+ if (cx < 0) cx = 0;
487
+ if (cy < 0) cy = 0;
488
+ if (cw > iw) cw = iw;
489
+ if (ch > ih) ch = ih;
490
+
491
+ ctx.drawImage(img, cx, cy, cw, ch, x, y, w, h);
492
+ }
493
+
494
+ /**
495
+ * Quebra texto em múltiplas linhas centralizadas
496
+ * @param {Object} ctx - Contexto de renderização 2D
497
+ * @param {string} text - Texto a ser quebrado
498
+ * @param {number} x - Posição X central
499
+ * @param {number} y - Posição Y inicial
500
+ * @param {number} maxWidth - Largura máxima
501
+ * @param {number} lineHeight - Altura da linha
502
+ * @returns {number} - Posição Y final
503
+ */
504
+ function wrapTextCentered(ctx, text, x, y, maxWidth, lineHeight) {
505
+ const words = text.split(" ");
506
+ let line = "";
507
+ let currentY = y;
508
+ const lines = [];
509
+
510
+ // Primeiro, calcula todas as linhas
511
+ for (let n = 0; n < words.length; n++) {
512
+ const testLine = line + words[n] + " ";
513
+ const metrics = ctx.measureText(testLine);
514
+ const testWidth = metrics.width;
515
+
516
+ if (testWidth > maxWidth && n > 0) {
517
+ lines.push(line.trim());
518
+ line = words[n] + " ";
519
+ } else {
520
+ line = testLine;
521
+ }
522
+ }
523
+ lines.push(line.trim());
524
+
525
+ // Ajusta Y para centralizar verticalmente
526
+ const totalHeight = lines.length * lineHeight;
527
+ currentY = y - totalHeight / 2 + lineHeight / 2;
528
+
529
+ // Desenha cada linha centralizada
530
+ const originalAlign = ctx.textAlign;
531
+ ctx.textAlign = "center";
532
+
533
+ for (const l of lines) {
534
+ ctx.fillText(l, x, currentY);
535
+ currentY += lineHeight;
536
+ }
537
+
538
+ ctx.textAlign = originalAlign;
539
+ return currentY;
540
+ }
541
+
542
+ /**
543
+ * Ajusta o brilho de uma cor
544
+ * @param {string} color - Cor em formato hexadecimal ou rgba
545
+ * @param {number} amount - Quantidade de ajuste (-1 a 1)
546
+ * @returns {string} - Cor ajustada
547
+ */
548
+ function adjustColor(color, amount) {
549
+ // Se for rgba, extrai os valores
550
+ if (color.startsWith("rgba")) {
551
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
552
+ if (match) {
553
+ let r = parseInt(match[1]);
554
+ let g = parseInt(match[2]);
555
+ let b = parseInt(match[3]);
556
+ const a = match[4] ? parseFloat(match[4]) : 1;
557
+
558
+ r = Math.max(0, Math.min(255, r + Math.round(255 * amount)));
559
+ g = Math.max(0, Math.min(255, g + Math.round(255 * amount)));
560
+ b = Math.max(0, Math.min(255, b + Math.round(255 * amount)));
561
+
562
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
563
+ }
564
+ }
565
+
566
+ // Se for hexadecimal
567
+ let hex = color.replace("#", "");
568
+
569
+ // Expande formato abreviado
570
+ if (hex.length === 3) {
571
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
572
+ }
573
+
574
+ let r = parseInt(hex.substring(0, 2), 16);
575
+ let g = parseInt(hex.substring(2, 4), 16);
576
+ let b = parseInt(hex.substring(4, 6), 16);
577
+
578
+ r = Math.max(0, Math.min(255, r + Math.round(255 * amount)));
579
+ g = Math.max(0, Math.min(255, g + Math.round(255 * amount)));
580
+ b = Math.max(0, Math.min(255, b + Math.round(255 * amount)));
581
+
582
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
583
+ }
584
+
585
+ /**
586
+ * Gera uma cor aleatória
587
+ * @param {number} minBrightness - Brilho mínimo (0-255)
588
+ * @param {number} maxBrightness - Brilho máximo (0-255)
589
+ * @returns {string} - Cor hexadecimal aleatória
590
+ */
591
+ function getRandomColor(minBrightness = 0, maxBrightness = 255) {
592
+ const range = maxBrightness - minBrightness;
593
+ const r = Math.floor(Math.random() * range + minBrightness);
594
+ const g = Math.floor(Math.random() * range + minBrightness);
595
+ const b = Math.floor(Math.random() * range + minBrightness);
596
+
597
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
598
+ }
599
+
600
+ /**
601
+ * Desenha uma estrela
602
+ * @param {Object} ctx - Contexto de renderização 2D
603
+ * @param {number} cx - Centro X
604
+ * @param {number} cy - Centro Y
605
+ * @param {number} spikes - Número de pontas
606
+ * @param {number} outerRadius - Raio externo
607
+ * @param {number} innerRadius - Raio interno
608
+ * @param {string} fillColor - Cor de preenchimento
609
+ * @param {string} strokeColor - Cor do contorno (opcional)
610
+ * @param {number} lineWidth - Largura do contorno (opcional)
611
+ */
612
+ function drawStar(ctx, cx, cy, spikes, outerRadius, innerRadius, fillColor, strokeColor = null, lineWidth = 1) {
613
+ let rot = Math.PI / 2 * 3;
614
+ let x = cx;
615
+ let y = cy;
616
+ const step = Math.PI / spikes;
617
+
618
+ ctx.beginPath();
619
+ ctx.moveTo(cx, cy - outerRadius);
620
+
621
+ for (let i = 0; i < spikes; i++) {
622
+ x = cx + Math.cos(rot) * outerRadius;
623
+ y = cy + Math.sin(rot) * outerRadius;
624
+ ctx.lineTo(x, y);
625
+ rot += step;
626
+
627
+ x = cx + Math.cos(rot) * innerRadius;
628
+ y = cy + Math.sin(rot) * innerRadius;
629
+ ctx.lineTo(x, y);
630
+ rot += step;
631
+ }
632
+
633
+ ctx.lineTo(cx, cy - outerRadius);
634
+ ctx.closePath();
635
+
636
+ if (fillColor) {
637
+ ctx.fillStyle = fillColor;
638
+ ctx.fill();
639
+ }
640
+
641
+ if (strokeColor) {
642
+ ctx.strokeStyle = strokeColor;
643
+ ctx.lineWidth = lineWidth;
644
+ ctx.stroke();
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Trunca texto para caber em uma largura máxima
650
+ * @param {Object} ctx - Contexto de renderização 2D
651
+ * @param {string} text - Texto a ser truncado
652
+ * @param {number} maxWidth - Largura máxima
653
+ * @param {string} ellipsis - Caracteres de truncamento (padrão: "...")
654
+ * @returns {string} - Texto truncado
655
+ */
656
+ function truncateText(ctx, text, maxWidth, ellipsis = "...") {
657
+ if (!text) return "";
658
+
659
+ const textWidth = ctx.measureText(text).width;
660
+ if (textWidth <= maxWidth) return text;
661
+
662
+ const ellipsisWidth = ctx.measureText(ellipsis).width;
663
+ let truncated = text;
664
+
665
+ while (ctx.measureText(truncated).width + ellipsisWidth > maxWidth && truncated.length > 0) {
666
+ truncated = truncated.slice(0, -1);
667
+ }
668
+
669
+ return truncated + ellipsis;
670
+ }
671
+
672
+ // --- Exportações ---
673
+ module.exports = {
674
+ // Funções de validação
675
+ isValidHexColor,
676
+
677
+ // Funções de gerenciamento de fontes
678
+ registerDefaultFonts,
679
+ registerFontIfNeeded,
680
+
681
+ // Funções de manipulação de imagens
682
+ loadImageWithAxios,
683
+ loadImage,
684
+ encodeToBuffer,
685
+ getBufferFromImage,
686
+ drawImageProp,
687
+
688
+ // Funções de desenho
689
+ roundRect,
690
+ wrapText,
691
+ wrapTextCentered,
692
+ applyTextShadow,
693
+ clearShadow,
694
+ createLinearGradient,
695
+ drawStar,
696
+
697
+ // Funções de cor
698
+ hexToRgba,
699
+ adjustColor,
700
+ getRandomColor,
701
+
702
+ // Funções de formatação
703
+ formatTime,
704
+ formatNumber,
705
+ truncateText,
706
+
707
+ // Constantes
708
+ DEFAULT_FONT_FAMILY
709
+ };
710
+