neekuro 2.0.4 → 2.1.0

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.
package/README.md CHANGED
@@ -83,13 +83,47 @@ const welcome = new Welcome()
83
83
  .setDescription('Al servidor de Discord');
84
84
 
85
85
  // Genera y guarda la imagen
86
- welcome.build()
86
+ welcome.build()
87
87
  .then(image => fs.writeFileSync('bienvenida.png', image))
88
88
  .then(() => console.log('Imagen creada correctamente!'))
89
89
  .catch(err => console.error('Error:', err.message));
90
90
 
91
91
  // nota: como antes, todo viene bien documentado en el paquete, solo sigue los pasos y explora por ti ;3
92
- ```
92
+ ```
93
+
94
+ Las imágenes remotas deben usar HTTPS. El paquete limita cada descarga a 10 MB,
95
+ aplica un tiempo máximo de 10 segundos, comprueba el formato y bloquea direcciones
96
+ locales o privadas. También puedes proporcionar un `Buffer` JPEG, PNG, GIF o WebP.
97
+
98
+ La resolución admite valores enteros entre 128 y 4096 píxeles. Para regresar al
99
+ tamaño original de 1140 × 520 puedes usar:
100
+
101
+ ```javascript
102
+ welcome.setResolution('default', 'default');
103
+ ```
104
+
105
+ Por defecto, el avatar, el título y la descripción forman una columna centrada
106
+ horizontal y verticalmente. El tamaño del avatar y de los textos se adapta cuando
107
+ la resolución es pequeña. Si necesitas coordenadas personalizadas puedes activar
108
+ el modo manual directamente o proporcionar `x`/`y` en cualquiera de los elementos:
109
+
110
+ ```javascript
111
+ welcome.setLayout('manual');
112
+
113
+ // Para regresar a la distribución automática:
114
+ welcome.setLayout('center');
115
+ ```
116
+
117
+ ## Desarrollo
118
+
119
+ ```sh
120
+ pnpm install
121
+ pnpm run check
122
+ pnpm run pack:check
123
+ ```
124
+
125
+ `pnpm run check` ejecuta las pruebas funcionales locales y comprueba las
126
+ declaraciones TypeScript. Ninguna de estas pruebas consulta la API remota.
93
127
  <hr>
94
128
  <center>
95
129
  <p>Resultado esperado</p>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "neekuro",
4
- "version": "2.0.4",
4
+ "version": "2.1.0",
5
5
  "description": "Una biblioteca de imágenes con temática de anime para bots de Discord.",
6
6
  "main": "sources/main/index.js",
7
7
  "types": "sources/typings/index.d.ts",
@@ -25,9 +25,9 @@
25
25
  "README.md"
26
26
  ],
27
27
  "contributors": [
28
- "cacao0027 <kzmkuro@gmail.com>"
28
+ "cacao0027 <kmzkuro@gmail.com>"
29
29
  ],
30
- "author": "cacao0027 <kzmkuro@gmail.com>",
30
+ "author": "cacao0027 <kmzkuro@gmail.com>",
31
31
  "keywords": [
32
32
  "anime",
33
33
  "acción",
@@ -40,17 +40,22 @@
40
40
  "images"
41
41
  ],
42
42
  "license": "ISC",
43
+ "packageManager": "pnpm@11.23.0",
43
44
  "scripts": {
44
- "test": "nodemon node ./test/main.test.js"
45
+ "test": "node --test test/*.test.js",
46
+ "check": "node --check sources/main/index.js && node --test test/*.test.js && tsc -p test/tsconfig.json",
47
+ "pack:check": "npm pack --dry-run"
45
48
  },
46
49
  "engines": {
47
50
  "node": ">=20.12.2"
48
51
  },
49
52
  "dependencies": {
50
53
  "canvas": "^3.1.0",
51
- "hex-color-regex": "^1.1.0",
52
- "neekuro": "file:",
53
- "node-fetch": "^3.3.2"
54
+ "hex-color-regex": "^1.1.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.0.0",
58
+ "typescript": "^5.9.0"
54
59
  },
55
60
  "repository": {
56
61
  "type": "git",
@@ -23,11 +23,13 @@ class SFW {
23
23
  * @param {GifMap[T]} gif corresponde al gif que quieras obtener por categoria
24
24
  * @returns {Promise<NekoGif>}
25
25
  */
26
- static async getGif(cat, gif) {
26
+ static async getGif(cat, gif) {
27
27
  if (!cat) throw new NekoError('NoInput', 'el parametro <cat> es requerido');
28
28
  if (!gif) throw new NekoError('NoInput', 'el parametro <gif> es requerido');
29
29
 
30
- let solicitud = new APIClient(base, { token: getToken() }), response = null;
30
+ const token = getToken();
31
+ if (!token) throw new NekoError('NoToken', 'Debes establecer un token antes de solicitar un GIF');
32
+ let solicitud = new APIClient(base, { token }), response = null;
31
33
  switch (cat) {
32
34
  case 'action':
33
35
  response = solicitud.get('/action/' + gif + '?random=true')
@@ -42,4 +44,4 @@ class SFW {
42
44
  }
43
45
  }
44
46
 
45
- module.exports = SFW
47
+ module.exports = SFW
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @typedef {import('../../typings').ApiResponse} ApiResponse
5
- * @typedef {import('node-fetch').Response} Response
5
+ * @typedef {{url?: string, status?: number}} ResponseLike
6
6
  */
7
7
 
8
8
  /**
@@ -14,15 +14,13 @@ class APIError extends Error {
14
14
  /**
15
15
  * Crea la instancia del error
16
16
  * @param {string} endpoint la ruta a la que se realizo la peticion
17
- * @param {Response} response respuesta de node-fetch
17
+ * @param {ResponseLike} response respuesta HTTP o representación de un error de red
18
18
  * @param {ApiResponse} data respuesta de la api
19
19
  */
20
- constructor(endpoint, response, data) {
21
- super();
20
+ constructor(endpoint, response, data) {
21
+ super(APIError.determineMessage(data));
22
22
 
23
23
  this.name = `NekoREST Error [${endpoint}]`
24
- this.message = this.#determineMessage(data);
25
-
26
24
  /**
27
25
  * @type {string}
28
26
  */
@@ -31,7 +29,7 @@ class APIError extends Error {
31
29
  /**
32
30
  * @type {number}
33
31
  */
34
- this.statusCode = parseInt(response?.status ?? 500);
32
+ this.statusCode = Number.parseInt(response?.status ?? 500, 10);
35
33
 
36
34
  /**
37
35
  * @type {ApiResponse}
@@ -45,7 +43,7 @@ class APIError extends Error {
45
43
  * @param {ApiResponse} response respuesta de la api
46
44
  * @returns {string} mensaje de error
47
45
  */
48
- #determineMessage(response) {
46
+ static determineMessage(response = {}) {
49
47
  if(response.code === 403) {
50
48
  return 'TOKEN ERROR: el token que haz proporcionado es invalido'
51
49
  } else
@@ -54,7 +52,11 @@ class APIError extends Error {
54
52
  } else {
55
53
  return response.message ?? 'descripcion no dada'
56
54
  }
57
- }
58
- }
59
-
60
- module.exports = APIError
55
+ }
56
+
57
+ static fromNetworkError(endpoint, url, message) {
58
+ return new APIError(endpoint, { url, status: 503 }, { code: 503, message });
59
+ }
60
+ }
61
+
62
+ module.exports = APIError
@@ -10,16 +10,17 @@ class NekoError extends Error {
10
10
  * @param {string} name Corresponde al nombre del error
11
11
  * @param {string} message Mensaje de error
12
12
  */
13
- constructor(name, message) {
14
- super(message);
13
+ constructor(name, message) {
14
+ super(message);
15
15
 
16
16
  this.name = `NeeKuro Error [${name}]`
17
17
 
18
18
  /**
19
19
  * @type {string}
20
20
  */
21
- this.message = message
21
+ this.message = message
22
+ Error.captureStackTrace?.(this, NekoError);
22
23
  }
23
24
  }
24
25
 
25
- module.exports = NekoError
26
+ module.exports = NekoError
@@ -14,19 +14,22 @@ class NekoGif {
14
14
  * Recibe la respuesta de la api
15
15
  * @param {GifResponse} response respuesta de la api
16
16
  */
17
- constructor(response) {
17
+ constructor(response) {
18
+ if (!response || typeof response !== 'object' || !response.data) {
19
+ throw new TypeError('La respuesta no contiene datos de imagen');
20
+ }
18
21
 
19
22
  /**
20
23
  * URL del gif
21
24
  * @type {string|null}
22
25
  */
23
- this.url = response.data.url || null
26
+ this.url = typeof response.data.url === 'string' ? response.data.url : null
24
27
 
25
28
  /**
26
29
  * Anime del gif
27
30
  * @type {string|null}
28
31
  */
29
- this.anime = response.data.anime || null
32
+ this.anime = typeof response.data.anime === 'string' ? response.data.anime : null
30
33
  }
31
34
 
32
35
  /**
@@ -47,4 +50,4 @@ class NekoGif {
47
50
 
48
51
  }
49
52
 
50
- module.exports = NekoGif
53
+ module.exports = NekoGif
@@ -2,7 +2,15 @@
2
2
  const Canvas = require('canvas');
3
3
  const NekoError = require('../errors/Error.js');
4
4
  const hex_reg = require('hex-color-regex')({ strict: true })
5
- const path = require('path');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const { loadImageSource, parseRemoteURL, validateImageBuffer } = require('../../utils/imageLoader.js');
8
+
9
+ const DEFAULT_WIDTH = 1140;
10
+ const DEFAULT_HEIGHT = 520;
11
+ const MIN_DIMENSION = 128;
12
+ const MAX_DIMENSION = 4096;
13
+ const registeredFonts = new Set();
6
14
 
7
15
  /**
8
16
  * @typedef {import('../../typings').FontNames} FontNames
@@ -29,7 +37,8 @@ class Welcome {
29
37
  constructor() {
30
38
  /**
31
39
  * @property {Object} data - Configuración actual de la imagen
32
- * @property {string} data.font - Fuente actualmente seleccionada
40
+ * @property {string} data.font - Fuente actualmente seleccionada
41
+ * @property {'center'|'manual'} data.layout - Distribución de los elementos
33
42
  * @property {number} data.width - Ancho actual de la imagen
34
43
  * @property {number} data.height - Alto actual de la imagen
35
44
  * @property {Object} data.background - Configuración del fondo
@@ -54,10 +63,11 @@ class Welcome {
54
63
  * @property {number} data.description.font_size - Tamaño de fuente de la descripción
55
64
  * @property {string} data.description.text_color - Color del texto de la descripción
56
65
  */
57
- this.data = {
58
- font: 'arial',
59
- width: 1140,
60
- height: 520,
66
+ this.data = {
67
+ font: 'arial',
68
+ layout: 'center',
69
+ width: DEFAULT_WIDTH,
70
+ height: DEFAULT_HEIGHT,
61
71
  background: {
62
72
  type: 'color',
63
73
  value: '#23272A'
@@ -83,8 +93,9 @@ class Welcome {
83
93
  font_size: 35,
84
94
  text_color: "#F7F7F7"
85
95
  }
86
- }
87
- }
96
+ }
97
+ this.setFont('arial');
98
+ }
88
99
 
89
100
  /**
90
101
  * Establece la resolución de la imagen de bienvenida
@@ -99,22 +110,28 @@ class Welcome {
99
110
  * // Restablecer a valores por defecto
100
111
  * welcome.setResolution('default', 'default');
101
112
  */
102
- setResolution(width, height) {
113
+ setResolution(width, height) {
103
114
  if (!width) throw new NekoError('ResolutionError', 'el parametro <width> no puede quedar vacio');
104
115
  if (!height) throw new NekoError('ResolutionError', 'el parametro <height> no puede quedar vacio');
105
116
 
106
- if (width == 'default') width = this.data.width; else {
107
- if (typeof width != 'number') throw new NekoError('TypeError', 'el parametro <width> solo acepta tipo \'number\', recibió ' + typeof width);
108
- this.data.width = width;
109
- }
110
-
111
- if (height == 'default') height = this.data.height; else {
112
- if (typeof height != 'number') throw new NekoError('TypeError', 'el parametro <height> solo acepta tipo \'number\', recibió ' + typeof height);
113
- this.data.height = height;
114
- }
115
-
116
- return this
117
- }
117
+ this.data.width = width === 'default' ? DEFAULT_WIDTH : validateDimension(width, 'width');
118
+ this.data.height = height === 'default' ? DEFAULT_HEIGHT : validateDimension(height, 'height');
119
+
120
+ return this
121
+ }
122
+
123
+ /**
124
+ * Selecciona la distribución automática centrada o las coordenadas manuales.
125
+ * @param {'center'|'manual'} layout
126
+ * @returns {Welcome}
127
+ */
128
+ setLayout(layout) {
129
+ if (!['center', 'manual'].includes(layout)) {
130
+ throw new NekoError('LayoutError', 'El layout debe ser "center" o "manual"');
131
+ }
132
+ this.data.layout = layout;
133
+ return this;
134
+ }
118
135
 
119
136
  /**
120
137
  * Configura la tipografía para el texto de la imagen
@@ -145,13 +162,18 @@ class Welcome {
145
162
  case 'custom':
146
163
  if (!dir) throw new NekoError('FontError', 'el parametro <dir> no puede quedar vacio en un tipo de fuente customizada');
147
164
  if (typeof dir != 'string') throw new NekoError('TypeError', 'el parametro <dir> solo acepta tipo \'string\', recibió ' + typeof dir);
148
- if (!dir.endsWith('ttf')) throw new NekoError('FontError', 'se nesecita un archivo ttf en el parametro <dir>')
149
- ruta = path.join(__dirname, '../../../../' + dir.replace('./', ''));
165
+ if (path.extname(dir).toLowerCase() !== '.ttf') throw new NekoError('FontError', 'se necesita un archivo TTF en el parametro <dir>')
166
+ ruta = path.resolve(dir);
150
167
  break;
151
168
  default: throw new NekoError('FontError', 'el parametro <name> no acepta ningun tipo diferente a los preterminados');
152
169
  }
153
170
  try {
154
- Canvas.registerFont(ruta, { family: name })
171
+ if (!fs.existsSync(ruta)) throw new Error('Font not found');
172
+ const fontKey = `${name}:${ruta}`;
173
+ if (!registeredFonts.has(fontKey)) {
174
+ Canvas.registerFont(ruta, { family: name });
175
+ registeredFonts.add(fontKey);
176
+ }
155
177
  } catch (error) {
156
178
  throw new NekoError('DirError', 'no se puede encontrar el directorio especificado')
157
179
  }
@@ -206,18 +228,20 @@ class Welcome {
206
228
  }
207
229
 
208
230
  // validacion de posiciones
209
- if (data.x) {
231
+ if (data.x !== undefined) {
210
232
  if (typeof data.x !== 'number') {
211
233
  throw new NekoError('TypeError', `La propiedad <x> solo acepta tipo 'number', recibió ${typeof data.x}`);
212
234
  }
213
- this.data.title.x = data.x;
235
+ this.data.title.x = data.x;
236
+ this.data.layout = 'manual';
214
237
  }
215
238
 
216
- if (data.y) {
239
+ if (data.y !== undefined) {
217
240
  if (typeof data.y !== 'number') {
218
241
  throw new NekoError('TypeError', `La propiedad <y> solo acepta tipo 'number', recibió ${typeof data.y}`);
219
242
  }
220
- this.data.title.y = data.y;
243
+ this.data.title.y = data.y;
244
+ this.data.layout = 'manual';
221
245
  }
222
246
  }
223
247
 
@@ -270,18 +294,20 @@ class Welcome {
270
294
  }
271
295
 
272
296
  // validacion de posiciones
273
- if (data.x) {
297
+ if (data.x !== undefined) {
274
298
  if (typeof data.x !== 'number') {
275
299
  throw new NekoError('TypeError', `La propiedad <x> solo acepta tipo 'number', recibió ${typeof data.x}`);
276
300
  }
277
- this.data.description.x = data.x;
301
+ this.data.description.x = data.x;
302
+ this.data.layout = 'manual';
278
303
  }
279
304
 
280
- if (data.y) {
305
+ if (data.y !== undefined) {
281
306
  if (typeof data.y !== 'number') {
282
307
  throw new NekoError('TypeError', `La propiedad <y> solo acepta tipo 'number', recibió ${typeof data.y}`);
283
308
  }
284
- this.data.description.y = data.y;
309
+ this.data.description.y = data.y;
310
+ this.data.layout = 'manual';
285
311
  }
286
312
  }
287
313
 
@@ -327,11 +353,9 @@ class Welcome {
327
353
  }
328
354
 
329
355
  if (typeof value == 'string') {
330
- try {
331
- new URL(value);
332
- } catch (error) {
333
- throw new NekoError('BackgroundError', 'La URL proporcionada no es válida');
334
- }
356
+ parseRemoteURL(value, 'el fondo');
357
+ } else {
358
+ validateImageBuffer(value, 'el fondo');
335
359
  }
336
360
 
337
361
  this.data.background = { type, value };
@@ -369,56 +393,33 @@ class Welcome {
369
393
  }
370
394
 
371
395
  if (typeof source === 'string') {
372
- try {
373
- const url = new URL(source);
374
-
375
- if (!['http:', 'https:'].includes(url.protocol)) {
376
- throw new NekoError('AvatarError', 'La URL debe usar HTTP/HTTPS');
377
- }
378
-
379
- const extension = url.pathname.toLowerCase().split('.').pop();
380
- if (!['jpg', 'jpeg', 'png', 'gif'].includes(extension)) {
381
- throw new NekoError('AvatarError', 'Formato de imagen no soportado. Usa JPEG, PNG o GIF');
382
- }
383
- } catch (error) {
384
- if (error instanceof NekoError) throw error;
385
- throw new NekoError('AvatarError', 'La URL del avatar no es válida');
386
- }
396
+ parseRemoteURL(source, 'el avatar');
387
397
  }
388
398
 
389
399
  if (Buffer.isBuffer(source)) {
390
- if (source.length < 8) {
391
- throw new NekoError('AvatarError', 'El Buffer de imagen es demasiado pequeño');
392
- }
393
-
394
- const header = source.toString('hex', 0, 8);
395
- const isJPEG = header.startsWith('ffd8ff');
396
- const isPNG = header.startsWith('89504e470d0a1a0a');
397
- const isGIF = header.startsWith('47494638');
398
-
399
- if (!isJPEG && !isPNG && !isGIF) {
400
- throw new NekoError('AvatarError', 'Formato de imagen no soportado. El Buffer debe ser JPEG, PNG o GIF');
401
- }
400
+ validateImageBuffer(source, 'el avatar');
402
401
  }
403
402
 
404
403
  this.data.avatar.source = source;
405
404
 
406
405
  if (data) {
407
- if (data.x) {
406
+ if (data.x !== undefined) {
408
407
  if (typeof data.x != 'number') {
409
408
  throw new NekoError('TypeError', 'La propiedad <x> debe ser un número');
410
409
  }
411
410
  if (data.x < 0) {
412
411
  throw new NekoError('AvatarError', 'La posición X no puede ser negativa');
413
412
  }
414
- this.data.avatar.x = data.x;
413
+ this.data.avatar.x = data.x;
414
+ this.data.layout = 'manual';
415
415
  }
416
416
 
417
- if (data.y) {
417
+ if (data.y !== undefined) {
418
418
  if (typeof data.y != 'number') {
419
419
  throw new NekoError('TypeError', 'La propiedad <y> debe ser un número');
420
420
  }
421
- this.data.avatar.y = data.y;
421
+ this.data.avatar.y = data.y;
422
+ this.data.layout = 'manual';
422
423
  }
423
424
 
424
425
  if (data.border) {
@@ -434,10 +435,13 @@ class Welcome {
434
435
  this.data.avatar.border = data.border;
435
436
  }
436
437
 
437
- if (data.radio) {
438
- if (typeof data.radio != 'number') {
439
- throw new NekoError('TypeError', 'La propiedad <radio> debe ser un número');
440
- }
438
+ if (data.radio !== undefined) {
439
+ if (typeof data.radio != 'number') {
440
+ throw new NekoError('TypeError', 'La propiedad <radio> debe ser un número');
441
+ }
442
+ if (!Number.isFinite(data.radio) || data.radio <= 0 || data.radio > Math.min(this.data.width, this.data.height) / 2) {
443
+ throw new NekoError('AvatarError', 'El radio debe ser positivo y caber dentro de la imagen');
444
+ }
441
445
  this.data.avatar.radio = data.radio;
442
446
  }
443
447
  }
@@ -464,8 +468,8 @@ class Welcome {
464
468
 
465
469
  try {
466
470
  const [bg, avatar] = await Promise.all([
467
- this.data.background.type == 'image' ? Canvas.loadImage(this.data.background.value) : null,
468
- Canvas.loadImage(this.data.avatar.source)
471
+ this.data.background.type == 'image' ? loadImageSource(this.data.background.value, 'el fondo') : null,
472
+ loadImageSource(this.data.avatar.source, 'el avatar')
469
473
  ])
470
474
 
471
475
  const canvas = Canvas.createCanvas(this.data.width, this.data.height)
@@ -481,35 +485,43 @@ class Welcome {
481
485
  ctx.fillRect(0, 0, canvas.width, canvas.height);
482
486
  }
483
487
 
484
- const maxTitleWidth = this.data.width * 0.8;
485
- let titleFontSize = this.data.title.font_size;
486
- ctx.font = `${titleFontSize}px ${this.data.font}`;
488
+ const maxTitleWidth = this.data.width * 0.8;
489
+ let titleFontSize = this.data.layout === 'center'
490
+ ? Math.min(this.data.title.font_size, this.data.height * 0.14)
491
+ : this.data.title.font_size;
492
+ ctx.font = `${titleFontSize}px ${this.data.font}`;
487
493
 
488
494
  while (ctx.measureText(this.data.title.content).width > maxTitleWidth && titleFontSize > 10) {
489
495
  titleFontSize -= 1;
490
496
  ctx.font = `${titleFontSize}px ${this.data.font}`;
491
497
  }
492
498
 
493
- ctx.fillStyle = this.data.title.text_color;
494
- ctx.textAlign = "center";
495
- ctx.fillText(this.data.title.content, this.data.title.x, this.data.title.y);
496
-
497
- const maxDescWidth = this.data.width * 0.9;
498
- let descFontSize = this.data.description.font_size;
499
- ctx.font = `${descFontSize}px ${this.data.font}`;
499
+ const maxDescWidth = this.data.width * 0.9;
500
+ let descFontSize = this.data.layout === 'center'
501
+ ? Math.min(this.data.description.font_size, this.data.height * 0.09)
502
+ : this.data.description.font_size;
503
+ ctx.font = `${descFontSize}px ${this.data.font}`;
500
504
 
501
505
  while (ctx.measureText(this.data.description.content).width > maxDescWidth && descFontSize > 8) {
502
506
  descFontSize -= 1;
503
507
  ctx.font = `${descFontSize}px ${this.data.font}`;
504
508
  }
505
509
 
506
- ctx.fillStyle = this.data.description.text_color;
507
- ctx.textAlign = "center";
508
- ctx.fillText(this.data.description.content, this.data.description.x, this.data.description.y);
509
-
510
- const x = this.data.avatar.x;
511
- const y = this.data.avatar.y;
512
- const radio = this.data.avatar.radio;
510
+ const layout = this.data.layout === 'center'
511
+ ? calculateCenteredLayout(this.data, titleFontSize, descFontSize)
512
+ : {
513
+ avatarX: this.data.avatar.x,
514
+ avatarY: this.data.avatar.y,
515
+ radio: this.data.avatar.radio,
516
+ titleX: this.data.title.x,
517
+ titleY: this.data.title.y,
518
+ descriptionX: this.data.description.x,
519
+ descriptionY: this.data.description.y
520
+ };
521
+
522
+ const x = layout.avatarX;
523
+ const y = layout.avatarY;
524
+ const radio = layout.radio;
513
525
 
514
526
  if (this.data.avatar.border) {
515
527
  ctx.beginPath();
@@ -524,8 +536,18 @@ class Welcome {
524
536
  ctx.arc(x + radio, y + radio, radio, 0, Math.PI * 2, true);
525
537
  ctx.closePath();
526
538
  ctx.clip();
527
- ctx.drawImage(avatar, x, y, radio * 2, radio * 2);
528
- ctx.restore();
539
+ ctx.drawImage(avatar, x, y, radio * 2, radio * 2);
540
+ ctx.restore();
541
+
542
+ ctx.textAlign = 'center';
543
+ ctx.textBaseline = this.data.layout === 'center' ? 'middle' : 'alphabetic';
544
+ ctx.font = `${titleFontSize}px ${this.data.font}`;
545
+ ctx.fillStyle = this.data.title.text_color;
546
+ ctx.fillText(this.data.title.content, layout.titleX, layout.titleY);
547
+
548
+ ctx.font = `${descFontSize}px ${this.data.font}`;
549
+ ctx.fillStyle = this.data.description.text_color;
550
+ ctx.fillText(this.data.description.content, layout.descriptionX, layout.descriptionY);
529
551
 
530
552
  return canvas.toBuffer();
531
553
  } catch (error) {
@@ -535,6 +557,50 @@ class Welcome {
535
557
  throw new NekoError('GenerateError', 'Error al generar la imagen: '+error.message)
536
558
  }
537
559
  }
538
- }
539
-
540
- module.exports = Welcome;
560
+ }
561
+
562
+ function calculateCenteredLayout(data, titleFontSize, descFontSize) {
563
+ const borderSize = data.avatar.border ? 6 : 0;
564
+ const avatarGap = clamp(data.height * 0.045, 16, 32);
565
+ const textGap = clamp(data.height * 0.018, 8, 16);
566
+ const availableHeight = data.height * 0.9;
567
+ const maxRadio = Math.max(
568
+ 16,
569
+ (availableHeight - titleFontSize - descFontSize - avatarGap - textGap) / 2 - borderSize
570
+ );
571
+ const radio = Math.min(data.avatar.radio, maxRadio);
572
+ const avatarOuterSize = (radio + borderSize) * 2;
573
+ const contentHeight = avatarOuterSize + avatarGap + titleFontSize + textGap + descFontSize;
574
+ const startY = (data.height - contentHeight) / 2;
575
+ const centerX = data.width / 2;
576
+ const avatarX = centerX - radio;
577
+ const avatarY = startY + borderSize;
578
+ const titleY = startY + avatarOuterSize + avatarGap + titleFontSize / 2;
579
+ const descriptionY = titleY + titleFontSize / 2 + textGap + descFontSize / 2;
580
+
581
+ return {
582
+ avatarX,
583
+ avatarY,
584
+ radio,
585
+ titleX: centerX,
586
+ titleY,
587
+ descriptionX: centerX,
588
+ descriptionY
589
+ };
590
+ }
591
+
592
+ function clamp(value, min, max) {
593
+ return Math.min(Math.max(value, min), max);
594
+ }
595
+
596
+ function validateDimension(value, name) {
597
+ if (typeof value !== 'number' || !Number.isInteger(value) || !Number.isFinite(value)) {
598
+ throw new NekoError('TypeError', `el parametro <${name}> debe ser un número entero`);
599
+ }
600
+ if (value < MIN_DIMENSION || value > MAX_DIMENSION) {
601
+ throw new NekoError('ResolutionError', `<${name}> debe estar entre ${MIN_DIMENSION} y ${MAX_DIMENSION} píxeles`);
602
+ }
603
+ return value;
604
+ }
605
+
606
+ module.exports = Welcome;
@@ -11,8 +11,11 @@ class BaseUser {
11
11
  * Establece el token "global" en todo el paquete.
12
12
  * @param {string} token token a establecer.
13
13
  */
14
- static setToken(token) {
15
- tokenGlobal = token;
14
+ static setToken(token) {
15
+ if (typeof token !== 'string' || !token.trim()) {
16
+ throw new TypeError('El token debe ser una cadena no vacía');
17
+ }
18
+ tokenGlobal = token;
16
19
  }
17
20
 
18
21
  /**
@@ -12,9 +12,9 @@ class User {
12
12
  * Puedes establecer el token aqui, sin embargo, recomiendo hacerlo en el metodo dedicado
13
13
  * @param {string} token Token de la api
14
14
  */
15
- constructor(token) {
16
- this.token_ = token
17
- setToken(this.#getToken());
15
+ constructor(token) {
16
+ this.token_ = undefined;
17
+ if (token !== undefined) this.token(token);
18
18
  }
19
19
 
20
20
  /**
@@ -23,9 +23,9 @@ class User {
23
23
  * @throws {NekoError} si el token no fue dado o no es una cadena
24
24
  */
25
25
  token(token) {
26
- if(!token) throw new NekoError('NoToken', 'el parametro <token> es requerido');
27
- if(typeof(token) !== 'string') throw new NekoError('InvalidType', `Token invaldo... el tipo de dato es String no ${typeof(token)}`);
28
- this.token_ = token;
26
+ if(typeof(token) !== 'string') throw new NekoError('InvalidType', `El token debe ser string, no ${typeof(token)}`);
27
+ if(!token.trim()) throw new NekoError('NoToken', 'el parametro <token> es requerido');
28
+ this.token_ = token.trim();
29
29
  setToken(this.#getToken());
30
30
  }
31
31
 
@@ -38,4 +38,4 @@ class User {
38
38
  }
39
39
  }
40
40
 
41
- module.exports = User
41
+ module.exports = User
@@ -8,7 +8,7 @@ exports.User = require('../classes/user/User.js')
8
8
 
9
9
  exports.NekoGif = require('../classes/package/NekoGif.js')
10
10
  exports.BaseUser = require('../classes/user/BaseUser.js')
11
- exports.Welcome = require('../classes/package/Welcome.js')
11
+ exports.Welcome = require('../classes/package/welcome.js')
12
12
 
13
13
  // rest
14
14
 
@@ -16,9 +16,11 @@ exports.APIClient = require('../rest/APIClient.js')
16
16
 
17
17
  // errores
18
18
 
19
- exports.APIError = require('../classes/errors/APIerror.js')
20
- exports.Error = require('../classes/errors/Error.js')
19
+ exports.APIError = require('../classes/errors/APIError.js')
20
+ exports.NekoError = require('../classes/errors/Error.js')
21
+ // Alias conservado por compatibilidad con versiones 2.0.x.
22
+ exports.Error = exports.NekoError
21
23
 
22
24
  //#region "constantes"
23
25
 
24
- exports.SFW = require('../classes/SFW.js')
26
+ exports.SFW = require('../classes/SFW.js')
@@ -5,9 +5,10 @@
5
5
  * @typedef {import('../typings').GifResponse} GifResponse
6
6
  */
7
7
 
8
- const APIError = require('../classes/errors/APIerror.js');
9
-
10
- const fetch = import('node-fetch');
8
+ const APIError = require('../classes/errors/APIError.js');
9
+ const NekoError = require('../classes/errors/Error.js');
10
+
11
+ const DEFAULT_TIMEOUT_MS = 10_000;
11
12
 
12
13
  /**
13
14
  * Clase APIClient
@@ -19,12 +20,28 @@ class APIClient {
19
20
  * @param {string} baseURL link principal de la api
20
21
  * @param {APIClientData} data objeto para las solicitudes
21
22
  */
22
- constructor(baseURL, data) {
23
+ constructor(baseURL, data) {
24
+ if (typeof baseURL !== 'string' || !baseURL) {
25
+ throw new NekoError('InvalidBaseURL', 'El parámetro <baseURL> debe ser una URL válida');
26
+ }
27
+
28
+ let parsedBaseURL;
29
+ try {
30
+ parsedBaseURL = new URL(baseURL);
31
+ } catch {
32
+ throw new NekoError('InvalidBaseURL', 'El parámetro <baseURL> debe ser una URL válida');
33
+ }
34
+ if (parsedBaseURL.protocol !== 'https:') {
35
+ throw new NekoError('InvalidBaseURL', 'La API debe utilizar HTTPS');
36
+ }
37
+ if (!data || typeof data.token !== 'string' || !data.token.trim()) {
38
+ throw new NekoError('NoToken', 'Se requiere un token para utilizar la API');
39
+ }
23
40
  /**
24
41
  * url principal
25
42
  * @type {string}
26
43
  */
27
- this.baseURL = baseURL
44
+ this.baseURL = baseURL.replace(/\/$/, '')
28
45
  /**
29
46
  * data
30
47
  * @type {APIClientData}
@@ -37,29 +54,53 @@ class APIClient {
37
54
  * @returns {Promise<GifResponse>}
38
55
  * @throws {APIError} solo si sucede un error entre solicitudes
39
56
  */
40
- async get(endpoint) {
41
- try {
42
- const response = await (await fetch).default(`${this.baseURL}${endpoint}`, {
43
- method: 'GET',
44
- headers: {
45
- 'Authorization': `${this.data.token}`,
46
- 'Content-Type': 'application/json'
47
- }
48
- });
49
-
50
- if (!response.ok) {
51
- throw new APIError(endpoint, response, await response.json());
52
- }
53
-
54
- return response.json()
55
- } catch (error) {
56
- if (error instanceof APIError) {
57
- throw error;
58
- } else {
59
- throw new APIError(endpoint, { message: error.message }, {});
60
- }
61
- }
62
- }
63
- }
57
+ async get(endpoint, options = {}) {
58
+ if (typeof endpoint !== 'string' || !endpoint.startsWith('/')) {
59
+ throw new NekoError('InvalidEndpoint', 'El endpoint debe comenzar con "/"');
60
+ }
61
+
62
+ const timeout = Number.isFinite(options.timeout) && options.timeout > 0
63
+ ? options.timeout
64
+ : DEFAULT_TIMEOUT_MS;
65
+ const controller = new AbortController();
66
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
67
+
68
+ try {
69
+ const response = await fetch(`${this.baseURL}${endpoint}`, {
70
+ method: 'GET',
71
+ headers: {
72
+ 'Authorization': `${this.data.token}`,
73
+ 'Content-Type': 'application/json'
74
+ },
75
+ signal: controller.signal
76
+ });
77
+
78
+ const data = await readJson(response);
79
+ if (!response.ok) {
80
+ throw new APIError(endpoint, response, data);
81
+ }
82
+
83
+ return data
84
+ } catch (error) {
85
+ if (error instanceof APIError) {
86
+ throw error;
87
+ }
88
+ const message = error?.name === 'AbortError'
89
+ ? `La solicitud excedió el tiempo límite de ${timeout} ms`
90
+ : error?.message ?? 'No fue posible conectar con la API';
91
+ throw APIError.fromNetworkError(endpoint, `${this.baseURL}${endpoint}`, message);
92
+ } finally {
93
+ clearTimeout(timeoutId);
94
+ }
95
+ }
96
+ }
97
+
98
+ async function readJson(response) {
99
+ try {
100
+ return await response.json();
101
+ } catch {
102
+ return { code: response.status, message: 'La API devolvió una respuesta que no es JSON' };
103
+ }
104
+ }
64
105
 
65
- module.exports = APIClient;
106
+ module.exports = APIClient;
@@ -1,6 +1,4 @@
1
- import { Response } from "node-fetch";
2
-
3
- //#region constants
1
+ //#region constants
4
2
 
5
3
  /**
6
4
  * Categoria SFW
@@ -109,8 +107,9 @@ export interface WelcomeTextData {
109
107
  text_color?: string
110
108
  }
111
109
 
112
- export interface WelcomeData {
113
- font: FontNames;
110
+ export interface WelcomeData {
111
+ font: FontNames;
112
+ layout: 'center' | 'manual';
114
113
  width: number;
115
114
  height: number;
116
115
  background: WelcomeBackgroundData;
@@ -147,8 +146,8 @@ export declare interface GifMap {
147
146
  reaction: ReactionGifs
148
147
  }
149
148
 
150
- export interface GifResponse extends ApiResponse {
151
- data?: {
149
+ export interface GifResponse extends ApiResponse {
150
+ data: {
152
151
  url: string;
153
152
  anime: string;
154
153
  }
@@ -187,7 +186,10 @@ export class Welcome {
187
186
  * @param height Alto en píxeles o 'default' para 450px
188
187
  * @throws {NekoError} Si los parámetros son inválidos
189
188
  */
190
- setResolution(width: number | 'default', height: number | 'default'): this;
189
+ setResolution(width: number | 'default', height: number | 'default'): this;
190
+
191
+ /** Selecciona distribución automática centrada o coordenadas manuales. */
192
+ setLayout(layout: 'center' | 'manual'): this;
191
193
 
192
194
  /**
193
195
  * Configura la tipografía para el texto de la imagen
@@ -253,7 +255,7 @@ export class User {
253
255
  * puedes establecer el token a partir de aqui, pero recomiendo usar el metodo dedicado
254
256
  * @param token Token de la api
255
257
  */
256
- constructor(token: string);
258
+ constructor(token?: string);
257
259
  /**
258
260
  * establece el token para usar la api
259
261
  * @example
@@ -314,7 +316,7 @@ export class BaseUser {
314
316
  * Clase APIClient
315
317
  * Se encarga de hacer las solicitudes al servidor
316
318
  */
317
- export class APIClient {
319
+ export class APIClient {
318
320
  /**
319
321
  * Instanciamiento de la clase
320
322
  * @param baseUrl baseUrl: link principal de la api
@@ -328,7 +330,7 @@ export class APIClient {
328
330
  * @param endpoint establece la ruta a la que se quiere comunicar en la api
329
331
  * @throws {APIError} solo si sucede un error entre solicitudes
330
332
  */
331
- public get(endpoint: string): Promise<GifResponse>
333
+ public get(endpoint: string, options?: { timeout?: number }): Promise<GifResponse>
332
334
  }
333
335
 
334
336
  /**
@@ -362,23 +364,26 @@ export class APIError extends Error {
362
364
  /**
363
365
  * Instanciamiento de un error de api
364
366
  * @param endpoint endpoint: ruta solicitada
365
- * @param response response: respuesta por node-fetch
367
+ * @param response respuesta HTTP o representación de un error de red
366
368
  * @param data data: objeto dado por la api
367
369
  */
368
- constructor(endpoint: string, response: Awaited<Response>, data: ApiResponse);
370
+ constructor(endpoint: string, response: { url?: string; status?: number }, data: ApiResponse);
369
371
  }
370
372
 
371
373
  /**
372
374
  * Clase NekoError
373
375
  * Errores personalizados respecto al paquete
374
376
  */
375
- export class NekoError extends Error {
377
+ export class NekoError extends Error {
376
378
  /**
377
379
  * Instanciamiento de un error del paquete
378
380
  * @param name name: nombre del error
379
381
  * @param message message: mensaje del error
380
382
  */
381
383
  constructor(name: string, message: string);
382
- }
384
+ }
385
+
386
+ /** Alias heredado de versiones 2.0.x. Prefiere NekoError. */
387
+ export { NekoError as Error };
383
388
 
384
- //#endregion
389
+ //#endregion
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const dns = require('node:dns').promises;
4
+ const net = require('node:net');
5
+ const Canvas = require('canvas');
6
+ const NekoError = require('../classes/errors/Error.js');
7
+
8
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
9
+ const IMAGE_TIMEOUT_MS = 10_000;
10
+ const MAX_REDIRECTS = 3;
11
+ const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
12
+
13
+ async function loadImageSource(source, label = 'imagen') {
14
+ if (Buffer.isBuffer(source)) {
15
+ validateImageBuffer(source, label);
16
+ return Canvas.loadImage(source);
17
+ }
18
+ return Canvas.loadImage(await downloadImage(source, label));
19
+ }
20
+
21
+ async function downloadImage(source, label = 'imagen') {
22
+ let currentURL = parseRemoteURL(source, label);
23
+
24
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
25
+ await assertPublicHostname(currentURL.hostname, label);
26
+ const controller = new AbortController();
27
+ const timeoutId = setTimeout(() => controller.abort(), IMAGE_TIMEOUT_MS);
28
+ let response;
29
+ try {
30
+ response = await fetch(currentURL, {
31
+ redirect: 'manual',
32
+ signal: controller.signal,
33
+ headers: { 'User-Agent': 'neekuro/2.1 image-loader' }
34
+ });
35
+ } catch (error) {
36
+ const message = error?.name === 'AbortError'
37
+ ? `La descarga de ${label} excedió ${IMAGE_TIMEOUT_MS} ms`
38
+ : `No se pudo descargar ${label}: ${error?.message ?? 'error desconocido'}`;
39
+ throw new NekoError('ImageDownloadError', message);
40
+ } finally {
41
+ clearTimeout(timeoutId);
42
+ }
43
+
44
+ if (response.status >= 300 && response.status < 400) {
45
+ const location = response.headers.get('location');
46
+ if (!location || redirects === MAX_REDIRECTS) {
47
+ throw new NekoError('ImageDownloadError', `Demasiadas redirecciones al descargar ${label}`);
48
+ }
49
+ currentURL = parseRemoteURL(new URL(location, currentURL).href, label);
50
+ continue;
51
+ }
52
+ if (!response.ok) {
53
+ throw new NekoError('ImageDownloadError', `No se pudo descargar ${label}: HTTP ${response.status}`);
54
+ }
55
+
56
+ const contentType = response.headers.get('content-type')?.split(';', 1)[0].toLowerCase();
57
+ if (!contentType || !IMAGE_CONTENT_TYPES.has(contentType)) {
58
+ throw new NekoError('ImageFormatError', `${label} no tiene un formato de imagen compatible`);
59
+ }
60
+ const declaredLength = Number(response.headers.get('content-length'));
61
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) {
62
+ throw new NekoError('ImageSizeError', `${label} supera el límite de 10 MB`);
63
+ }
64
+ return readLimitedBody(response, label);
65
+ }
66
+ throw new NekoError('ImageDownloadError', `No se pudo descargar ${label}`);
67
+ }
68
+
69
+ function parseRemoteURL(source, label) {
70
+ if (typeof source !== 'string') {
71
+ throw new NekoError('ImageURLerror', `${label} debe ser una URL HTTPS o un Buffer`);
72
+ }
73
+ let url;
74
+ try {
75
+ url = new URL(source);
76
+ } catch {
77
+ throw new NekoError('ImageURLerror', `La URL de ${label} no es válida`);
78
+ }
79
+ if (url.protocol !== 'https:' || url.username || url.password) {
80
+ throw new NekoError('ImageURLerror', `La URL de ${label} debe utilizar HTTPS y no incluir credenciales`);
81
+ }
82
+ return url;
83
+ }
84
+
85
+ async function assertPublicHostname(hostname, label) {
86
+ const normalized = hostname.toLowerCase().replace(/\.$/, '');
87
+ if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized.endsWith('.local')) {
88
+ throw new NekoError('ImageURLerror', `La URL de ${label} apunta a una red local`);
89
+ }
90
+ const addresses = net.isIP(normalized)
91
+ ? [{ address: normalized }]
92
+ : await dns.lookup(normalized, { all: true, verbatim: true }).catch(() => []);
93
+ if (!addresses.length || addresses.some(({ address }) => isPrivateAddress(address))) {
94
+ throw new NekoError('ImageURLerror', `La URL de ${label} no apunta a una dirección pública`);
95
+ }
96
+ }
97
+
98
+ function isPrivateAddress(address) {
99
+ if (net.isIPv4(address)) {
100
+ const [a, b] = address.split('.').map(Number);
101
+ return a === 0 || a === 10 || a === 127 ||
102
+ (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
103
+ (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ||
104
+ (a === 198 && (b === 18 || b === 19)) || a >= 224;
105
+ }
106
+ if (net.isIPv6(address)) {
107
+ const value = address.toLowerCase();
108
+ return value === '::' || value === '::1' || value.startsWith('fc') || value.startsWith('fd') ||
109
+ /^fe[89ab]/.test(value) || value.startsWith('ff') || value.startsWith('2001:db8:') ||
110
+ (value.startsWith('::ffff:') && isPrivateAddress(value.slice(7)));
111
+ }
112
+ return true;
113
+ }
114
+
115
+ async function readLimitedBody(response, label) {
116
+ if (!response.body) throw new NekoError('ImageDownloadError', `${label} no contiene datos`);
117
+ const chunks = [];
118
+ let size = 0;
119
+ for await (const chunk of response.body) {
120
+ const buffer = Buffer.from(chunk);
121
+ size += buffer.length;
122
+ if (size > MAX_IMAGE_BYTES) {
123
+ await response.body.cancel().catch(() => undefined);
124
+ throw new NekoError('ImageSizeError', `${label} supera el límite de 10 MB`);
125
+ }
126
+ chunks.push(buffer);
127
+ }
128
+ const result = Buffer.concat(chunks);
129
+ validateImageBuffer(result, label);
130
+ return result;
131
+ }
132
+
133
+ function validateImageBuffer(buffer, label = 'imagen') {
134
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) {
135
+ throw new NekoError('ImageFormatError', `${label} no contiene una imagen válida`);
136
+ }
137
+ if (buffer.length > MAX_IMAGE_BYTES) {
138
+ throw new NekoError('ImageSizeError', `${label} supera el límite de 10 MB`);
139
+ }
140
+ const header = buffer.subarray(0, 12).toString('hex');
141
+ const valid = header.startsWith('ffd8ff') || header.startsWith('89504e470d0a1a0a') ||
142
+ header.startsWith('47494638') || (header.slice(0, 8) === '52494646' && header.slice(16, 24) === '57454250');
143
+ if (!valid) throw new NekoError('ImageFormatError', `${label} debe ser JPEG, PNG, GIF o WebP`);
144
+ }
145
+
146
+ module.exports = { IMAGE_TIMEOUT_MS, MAX_IMAGE_BYTES, isPrivateAddress, loadImageSource, parseRemoteURL, validateImageBuffer };