utn-cli 2.0.87 → 2.0.89

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.
@@ -0,0 +1,797 @@
1
+ const path = require("path");
2
+ const PDFDocument = require('pdfkit');
3
+ const crypto = require('crypto');
4
+ const { ejecutarConsultaSIGU } = require('./db.js');
5
+ const Miscelaneas = require('./Miscelaneas.js');
6
+
7
+ class ReportePDF {
8
+ constructor() {
9
+ this.config = {
10
+ margin: 50,
11
+ colorPrimario: "#1F2A63",
12
+ colorSecundario: "#666666",
13
+ colorFondoAlterno: "#F9F9F9",
14
+ colorLinea: "#DDD",
15
+ fuenteBase: "Helvetica",
16
+ fuenteNegrita: "Helvetica-Bold",
17
+ fuenteCursiva: "Helvetica-Oblique",
18
+ fuenteNegritaCursiva: "Helvetica-BoldOblique",
19
+ fontSizeTitulo: 14,
20
+ fontSizeSubtitulo: 10,
21
+ fontSizeCuerpo: 9,
22
+ fontSizePequeño: 8,
23
+ fontSizeMuyPequeño: 7
24
+ };
25
+ }
26
+
27
+ // const datosUsuario = await Miscelaneo.obtenerDatosDelUsuario(solicitud.headers.authorization);
28
+ // const Fecha = solicitud.params.Fecha || solicitud.query.Fecha;
29
+ // const titulos = {
30
+ // Principal: "Histórico de asignación de periodos",
31
+ // Subtitulo1: `Fecha de proceso: ${Fecha}`,
32
+ // Subtitulo2: "Reporte generado por el Área de Administración de Servicios",
33
+ // Direccion: "Dirección de Gestión de Desarrollo Humano",
34
+ // Area: "Área de Administración de Servicios AAS",
35
+ // Usuario: datosUsuario?.Identificacion || "Sistema",
36
+ // pieDePagina: "Copia digital del documento original.\nPara fines informáticos únicamente.",
37
+ // Acceso: "Privado",
38
+ // Uso: "Interno"
39
+ // };
40
+ // const metadatos = [
41
+ // { llave: 'Identificacion', titulo: 'Cédula', ancho: 80 },
42
+ // { llave: 'Nombre', titulo: 'Nombre Completo', ancho: 180 },
43
+ // { llave: 'Periodo', titulo: 'Período', ancho: 70 },
44
+ // { llave: 'Estamento', titulo: 'Estamento', ancho: 80 },
45
+ // { llave: 'DiasOtorgados', titulo: 'Días', ancho: 40 },
46
+ // { llave: 'Salario', titulo: 'Salario', ancho: 45 }
47
+ // ];
48
+ // const doc = ReportePDF.generarEncabezado(titulos, respuesta);
49
+ // const parrafo = 'Para más información, visite el <a href="https://www.utn.ac.cr">sitio web de la UTN</a>.';
50
+ // ReportePDF.generarParrafoHTML(doc, parrafo);
51
+ // ReportePDF.generarTituloDePagina(doc, titulos.Principal, titulos.Subtitulo1, titulos.Subtitulo2);
52
+ // ReportePDF.generarTabla(doc, datos, metadatos);
53
+ // let firmas = [
54
+ // {
55
+ // Nombre: "DAVID VILLALOBOS CAMBRONERO",
56
+ // Identificacion: "111050570",
57
+ // Instancia: "Dirección de TI",
58
+ // Fecha: "17-04-2026 14:30",
59
+ // Motivo: "Aprobación de vacaciones"
60
+ // },
61
+ // {
62
+ // Nombre: "JUAN PEREZ",
63
+ // Identificacion: "123456789",
64
+ // Instancia: "Recursos Humanos",
65
+ // Fecha: "17-04-2026 14:35",
66
+ // Motivo: "Validación de saldos"
67
+ // },
68
+ // {
69
+ // Nombre: "JUAN PEREZ",
70
+ // Identificacion: "123456789",
71
+ // Instancia: "Recursos Humanos",
72
+ // Fecha: "17-04-2026 14:35",
73
+ // Motivo: "Revisión técnica"
74
+ // },
75
+ // {
76
+ // Nombre: "JUAN PEREZ",
77
+ // Identificacion: "123456789",
78
+ // Instancia: "Recursos Humanos",
79
+ // Fecha: "17-04-2026 14:35",
80
+ // Motivo: "Revisión técnica"
81
+ // },
82
+ // {
83
+ // Nombre: "JUAN PEREZ",
84
+ // Identificacion: "123456789",
85
+ // Instancia: "Recursos Humanos",
86
+ // Fecha: "17-04-2026 14:35",
87
+ // Motivo: "Revisión técnica"
88
+ // },
89
+ // {
90
+ // Nombre: "JUAN PEREZ",
91
+ // Identificacion: "123456789",
92
+ // Instancia: "Recursos Humanos",
93
+ // Fecha: "17-04-2026 14:35",
94
+ // Motivo: "Revisión técnica"
95
+ // }
96
+ // ];
97
+ // ReportePDF.generarFirmas(doc, firmas);
98
+ // firmas = [
99
+ // {
100
+ // Nombre: "DAVID VILLALOBOS CAMBRONERO",
101
+ // Identificacion: "111050570",
102
+ // Instancia: "Dirección de TI",
103
+ // Fecha: "17-04-2026 14:30",
104
+ // Motivo: "Aprobación de vacaciones"
105
+ // }
106
+ // ]
107
+ // ReportePDF.generarFirmas(doc, firmas);
108
+ // firmas = [
109
+ // {
110
+ // Nombre: "DAVID VILLALOBOS CAMBRONERO",
111
+ // Identificacion: "111050570",
112
+ // Instancia: "Dirección de TI",
113
+ // Fecha: "17-04-2026 14:30",
114
+ // Motivo: "Aprobación de vacaciones"
115
+ // },
116
+ // {
117
+ // Nombre: "DAVID VILLALOBOS CAMBRONERO",
118
+ // Identificacion: "111050570",
119
+ // Instancia: "Dirección de TI",
120
+ // Fecha: "17-04-2026 14:30",
121
+ // Motivo: "Aprobación de vacaciones"
122
+ // }
123
+ // ]
124
+ // ReportePDF.generarFirmas(doc, firmas);
125
+ // ReportePDF.generarTituloDePagina(doc, titulos.Principal, titulos.Subtitulo1, titulos.Subtitulo2);
126
+ // ReportePDF.generarTituloDePagina(doc, titulos.Principal, titulos.Subtitulo1, titulos.Subtitulo2);
127
+ // ReportePDF.generarTabla(doc, datos, metadatos);
128
+ // ReportePDF.generarTabla(doc, datos, metadatos);
129
+ // const parrafoIntroductorio = `Este <b>reporte</b> detalla la asignación <u>automática</u> de periodos de vacaciones realizada en la fecha seleccionada. <i>Los datos mostrados</i> corresponden al estado final de cada registro tras el proceso de cálculo.`;
130
+ // ReportePDF.generarParrafoHTML(doc, parrafoIntroductorio);
131
+ // ReportePDF.generarTabla(doc, datos, metadatos);
132
+ // ReportePDF.generarSaltoDePagina(doc);
133
+ // ReportePDF.generarTabla(doc, datos, metadatos);
134
+ // ReportePDF.generarParrafoHTML(doc, parrafo);
135
+ // ReportePDF.generarMarcaDeAgua(doc, "TEXTO DEL SELLO", "#FF0000");
136
+ // ReportePDF.generarFoliado(doc, 100);
137
+ // ReportePDF.generarPie(doc, "Copia digital del documento original.\nPara fines informáticos únicamente.");
138
+ // const MetaDatos = {
139
+ // Titulo: "UTN-1-2025-10841",
140
+ // Identificador: 12,
141
+ // Autor: "VALLADARES BONILLA TONNY MAURICIO 603230047",
142
+ // Asunto: "Boleta de Vacaciones"
143
+ // };
144
+ // await ReportePDF.generarMetadatosReporte(doc, MetaDatos);
145
+ // ReportePDF.finalizarReporte(doc);
146
+
147
+ async verificarSiReporteYaExiste(Modulo, Titulo, Identificador) {
148
+ try {
149
+ const query = `SELECT COUNT(*) AS total FROM SIGU.SIGU_ReportesGenerados WHERE Modulo = ? AND Titulo = ? AND Identificador = ?`;
150
+ const resultado = await ejecutarConsultaSIGU(query, [Modulo, Titulo, Identificador]);
151
+ return resultado[0].total > 0;
152
+ } catch (error) {
153
+ console.error("Error al verificar existencia del reporte:", error);
154
+ return false;
155
+ }
156
+ }
157
+
158
+ async generarMetadatosReporte(doc, opciones) {
159
+ try {
160
+ const {
161
+ Modulo = Miscelaneas.getNombreDelRepositorioDelBackend(),
162
+ Titulo = 'Sin Título',
163
+ Identificador = 0,
164
+ Autor = 'Sistema',
165
+ Asunto = 'Reporte'
166
+ } = opciones;
167
+
168
+ const yaExiste = await this.verificarSiReporteYaExiste(Modulo, Titulo, Identificador);
169
+ if (yaExiste) {
170
+ return true;
171
+ }
172
+
173
+ // Generar UUID
174
+ const UUIDResult = await ejecutarConsultaSIGU("SELECT UUID() AS Dato");
175
+ const uuid = UUIDResult[0].Dato;
176
+
177
+ // Generar fecha y hora actual en formato MySQL
178
+ const now = new Date();
179
+ // Formatear al huso horario local de Costa Rica o general YYYY-MM-DD HH:mm:ss
180
+ // Una forma segura y simple en Node para obtener local sin T ni Z
181
+ const offsetMs = now.getTimezoneOffset() * 60 * 1000;
182
+ const localTime = new Date(now.getTime() - offsetMs);
183
+ const fechaYHora = localTime.toISOString().replace('T', ' ').substring(0, 19);
184
+
185
+ // Preparar Metadatos según estructura solicitada
186
+ const metadatos = {
187
+ "Título": Titulo,
188
+ "Autor": Autor,
189
+ "Asunto": Asunto,
190
+ "Palabras clave": {
191
+ "Módulo": Modulo,
192
+ "UUID": uuid
193
+ },
194
+ "Fecha de creación": fechaYHora
195
+ };
196
+
197
+ // Calcular cantidad de páginas
198
+ let totalPaginas = 1;
199
+ // Primero intentamos con bufferedPageRange si se inicializó con { bufferPages: true }
200
+ if (typeof doc.bufferedPageRange === 'function') {
201
+ try {
202
+ const range = doc.bufferedPageRange();
203
+ if (range && range.count > 0) {
204
+ totalPaginas = range.count;
205
+ }
206
+ } catch (e) {
207
+ // Si bufferPages no está activo, PDFKit a veces lanza un error al llamar a la función
208
+ }
209
+ }
210
+
211
+ // Si falló el buffer, intentamos recuperar el número de la página en la que quedó el cursor.
212
+ if (totalPaginas === 1 && doc._pageBuffer && doc._pageBuffer.length > 0) {
213
+ totalPaginas = doc._pageBuffer.length;
214
+ }
215
+
216
+ // Suma de comprobación usando crypto sha256
217
+ const hashStr = `${uuid}-${Modulo}-${fechaYHora}`;
218
+ const sumaDeComprobacion = crypto.createHash('sha256').update(hashStr).digest('hex');
219
+
220
+ // Preparar MetadatosExtra
221
+ const metadatosExtra = {
222
+ "CantidadDePáginas": totalPaginas,
223
+ "ValidaFechaDeModificación": "TRUE",
224
+ "SumaDeComprobacion": sumaDeComprobacion
225
+ };
226
+
227
+ // Adjuntar metadatos al documento PDF
228
+ doc.info['Title'] = metadatos['Título'];
229
+ doc.info['Author'] = metadatos['Autor'];
230
+ doc.info['Subject'] = metadatos['Asunto'];
231
+ doc.info['Keywords'] = JSON.stringify(metadatos['Palabras clave']);
232
+ // Añadir la cadena JSON explícitamente en el PDF si se requiere para validaciones futuras
233
+ doc.info['Metadatos'] = JSON.stringify(metadatos);
234
+ doc.info['MetadatosExtra'] = JSON.stringify(metadatosExtra);
235
+
236
+ // Insertar el registro en la base de datos
237
+ await ejecutarConsultaSIGU(
238
+ `INSERT INTO SIGU.SIGU_ReportesGenerados
239
+ (IdentificadorDelReporte, Modulo, Titulo, Identificador, Metadatos, MetadatosExtra, FechaYHora)
240
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
241
+ [
242
+ uuid,
243
+ Modulo,
244
+ Titulo,
245
+ Identificador,
246
+ JSON.stringify(metadatos),
247
+ JSON.stringify(metadatosExtra),
248
+ fechaYHora
249
+ ]
250
+ );
251
+
252
+ return true;
253
+ } catch (error) {
254
+ console.error("Error al generar metadatos del reporte PDF:", error);
255
+ return false;
256
+ }
257
+ }
258
+
259
+ finalizarReporte(doc) {
260
+ doc.end();
261
+ }
262
+
263
+ generarEncabezado(Titulos, respuesta) {
264
+ respuesta.setHeader("Content-Type", "application/pdf");
265
+ respuesta.setHeader("Content-Disposition", `inline; filename=Reporte.pdf`);
266
+ const doc = new PDFDocument({
267
+ size: 'Letter',
268
+ margins: { top: 105, left: this.config.margin, right: this.config.margin, bottom: this.config.margin },
269
+ bufferPages: true
270
+ });
271
+ doc.pipe(respuesta);
272
+
273
+ // Función interna para dibujar el encabezado
274
+ const dibujarEncabezado = () => {
275
+ const margin = this.config.margin;
276
+ const logoWidth = 70;
277
+ const pageWidth = doc.page.width;
278
+
279
+ // Logo UTN
280
+ try {
281
+ const rutaLogo = path.join(__dirname, "./LogoUTN.png");
282
+ // Subimos el logo ligeramente (Y=35 en vez de 40)
283
+ doc.image(rutaLogo, margin, 35, { width: logoWidth });
284
+ } catch (e) { console.error("Logo no encontrado", e); }
285
+
286
+ doc.fillColor(this.config.colorPrimario).fontSize(11).font(this.config.fuenteNegrita);
287
+ doc.text("Universidad Técnica Nacional", margin + logoWidth + 15, 45);
288
+ doc.fontSize(9).font(this.config.fuenteBase);
289
+ doc.text(Titulos.Direccion, margin + logoWidth + 15, 58);
290
+ doc.text(Titulos.Area, margin + logoWidth + 15, 70);
291
+
292
+ // Bloque derecho con información del documento
293
+ const now = new Date();
294
+ const fechaActual = now.toLocaleDateString('es-CR');
295
+ const horaActual = now.toLocaleTimeString('es-CR', { hour: '2-digit', minute: '2-digit', hour12: false });
296
+ doc.fontSize(this.config.fontSizePequeño).fillColor(this.config.colorPrimario);
297
+ doc.text(`Fecha: ${fechaActual}`, pageWidth - margin - 200, 45, { align: 'right', width: 200 });
298
+ doc.text(`Hora: ${horaActual}`, pageWidth - margin - 200, 55, { align: 'right', width: 200 });
299
+ doc.text(`Usuario: ${Titulos.Usuario}`, pageWidth - margin - 200, 65, { align: 'right', width: 200 });
300
+
301
+ const acceso = Titulos.Acceso || 'Interno';
302
+ const uso = Titulos.Uso || 'Público';
303
+ doc.text(`Acceso: ${acceso}, Uso: ${uso}`, pageWidth - margin - 200, 75, { align: 'right', width: 200 });
304
+
305
+ // Línea separadora
306
+ doc.moveTo(margin, 90).lineTo(pageWidth - margin, 90).strokeColor(this.config.colorPrimario).lineWidth(2).stroke();
307
+
308
+ // Asegurar que el cursor quede posicionado debajo del encabezado
309
+ doc.x = margin;
310
+ doc.y = 105;
311
+ };
312
+ // Evento para añadir el encabezado institucional automáticamente en CADA NUEVA PÁGINA
313
+ doc.on('pageAdded', dibujarEncabezado);
314
+ // Dibujar el encabezado en la PRIMERA PÁGINA (que ya fue creada al instanciar PDFDocument)
315
+ dibujarEncabezado();
316
+ return doc;
317
+ }
318
+
319
+ generarTituloDePagina(doc, Titulo, Subtitulo1 = undefined, Subtitulo2 = undefined) {
320
+ const margin = this.config.margin;
321
+ const pageWidth = doc.page.width;
322
+
323
+ // Alinear el cursor al margen izquierdo antes de imprimir
324
+ doc.x = margin;
325
+
326
+ // Título y subtítulos
327
+ doc.fillColor("black").fontSize(this.config.fontSizeTitulo).font(this.config.fuenteNegrita);
328
+
329
+ // Al no pasar coordenadas X e Y fijas, PDFKit renderiza el texto en la
330
+ // posición actual de doc.y fluyendo naturalmente hacia abajo.
331
+ doc.text(Titulo, { align: 'center', width: pageWidth - 2 * margin });
332
+
333
+ doc.fontSize(this.config.fontSizeSubtitulo).font(this.config.fuenteBase);
334
+
335
+ if (Subtitulo1) {
336
+ doc.moveDown(0.5);
337
+ doc.text(Subtitulo1, { align: 'center', width: pageWidth - 2 * margin });
338
+ }
339
+
340
+ if (Subtitulo2) {
341
+ doc.moveDown(0.5);
342
+ doc.text(Subtitulo2, { align: 'center', width: pageWidth - 2 * margin });
343
+ }
344
+
345
+ // Mover el cursor un poco hacia abajo para el siguiente contenido
346
+ doc.moveDown(2);
347
+ }
348
+
349
+ generarPie(doc, PieDePagina = undefined) {
350
+ const range = doc.bufferedPageRange();
351
+ const textoPieDePagina = PieDePagina || '';
352
+ for (let i = range.start; i < range.start + range.count; i++) {
353
+ doc.switchToPage(i);
354
+ const indexPagina = i + 1;
355
+ const totalPaginas = range.count;
356
+ const margin = this.config.margin;
357
+ const pageWidth = doc.page.width;
358
+ const footerY = doc.page.height - 50;
359
+
360
+ // Desactivar temporalmente el margen inferior para evitar saltos de página accidentales
361
+ const bottomMargin = doc.page.margins.bottom;
362
+ doc.page.margins.bottom = 0;
363
+
364
+ // Raya horizontal superior del pie
365
+ doc.moveTo(margin, footerY).lineTo(pageWidth - margin, footerY).strokeColor(this.config.colorPrimario).lineWidth(1).stroke();
366
+
367
+ // Raya vertical en el centro como separador
368
+ const centerX = pageWidth / 2;
369
+
370
+ doc.fillColor(this.config.colorPrimario).fontSize(this.config.fontSizeMuyPequeño).font(this.config.fuenteBase);
371
+
372
+ // Texto a la izquierda
373
+ doc.text(textoPieDePagina || "", margin, footerY + 10, { width: (pageWidth / 2) - margin - 15, align: 'left' });
374
+
375
+ // Paginación a la derecha (X de Y)
376
+ const paginacion = `Página ${indexPagina} de ${totalPaginas}`;
377
+ // Como el texto de la izquierda tiene 2 líneas, alineamos la paginación un poco más abajo para que quede centrado
378
+ doc.text(paginacion, centerX + 15, footerY + 14, { width: (pageWidth / 2) - margin - 15, align: 'right' });
379
+
380
+ // Restaurar el margen inferior original
381
+ doc.page.margins.bottom = bottomMargin;
382
+ }
383
+ // doc.end();
384
+ }
385
+
386
+ generarFoliado(doc, folioInicial) {
387
+ if (!doc || folioInicial === undefined) return;
388
+
389
+ const range = doc.bufferedPageRange();
390
+ let folioActual = parseInt(folioInicial);
391
+
392
+ for (let i = range.start; i < range.start + range.count; i++) {
393
+ doc.switchToPage(i);
394
+
395
+ const margin = this.config.margin;
396
+ const pageWidth = doc.page.width;
397
+ const yPos = 30; // Posición en la parte superior, fuera del área del encabezado principal
398
+
399
+ doc.save();
400
+ doc.fillColor("black")
401
+ .fontSize(this.config.fontSizeSubtitulo)
402
+ .font(this.config.fuenteNegrita);
403
+
404
+ doc.text(String(folioActual), pageWidth - margin - 100, yPos, {
405
+ width: 100,
406
+ align: 'right'
407
+ });
408
+
409
+ doc.restore();
410
+ folioActual++;
411
+ }
412
+ }
413
+
414
+ generarMarcaDeAgua(doc, texto, color = "#CCCCCC") {
415
+ if (!doc || !texto) return;
416
+
417
+ const range = doc.bufferedPageRange();
418
+ for (let i = range.start; i < range.start + range.count; i++) {
419
+ doc.switchToPage(i);
420
+
421
+ const pageWidth = doc.page.width;
422
+ const pageHeight = doc.page.height;
423
+
424
+ doc.save();
425
+
426
+ // Configuración de la marca de agua
427
+ doc.fillColor(color);
428
+ doc.fillOpacity(0.15); // Transparencia para que no tape el contenido
429
+ doc.fontSize(60);
430
+ doc.font(this.config.fuenteNegrita);
431
+
432
+ // Posicionamiento central con rotación
433
+ doc.translate(pageWidth / 2, pageHeight / 2);
434
+ doc.rotate(-45);
435
+
436
+ // Dibujar el texto centrado en el eje rotado
437
+ doc.text(texto, -pageWidth / 2, -30, {
438
+ width: pageWidth,
439
+ align: 'center'
440
+ });
441
+
442
+ doc.restore();
443
+ }
444
+ }
445
+
446
+ generarTabla(doc, datos, metadatos) {
447
+ if (!datos || datos.length === 0) return;
448
+
449
+ const margin = this.config.margin;
450
+ const pageWidth = doc.page.width;
451
+ const availableWidth = pageWidth - 2 * margin;
452
+
453
+ // Dibujar encabezado de la tabla
454
+ const dibujarHeaderTabla = (y) => {
455
+ doc.rect(margin, y, availableWidth, 22).fill(this.config.colorPrimario);
456
+ doc.fillColor("white").font(this.config.fuenteNegrita).fontSize(this.config.fontSizeCuerpo);
457
+ let cx = margin;
458
+ metadatos.forEach(col => {
459
+ doc.text(col.titulo, cx + 5, y + 7, { width: col.ancho - 10, align: 'left' });
460
+ cx += col.ancho;
461
+ });
462
+ return y + 22;
463
+ };
464
+
465
+ let currentY = doc.y;
466
+
467
+ // Si estamos muy cerca del final, empezamos en página nueva
468
+ if (currentY > doc.page.height - 100) {
469
+ doc.addPage();
470
+ currentY = doc.y; // doc.y ya viene ajustado por el encabezado institucional
471
+ }
472
+
473
+ currentY = dibujarHeaderTabla(currentY);
474
+ doc.font(this.config.fuenteBase).fontSize(this.config.fontSizePequeño).fillColor("black");
475
+
476
+ datos.forEach((fila, index) => {
477
+ const rowHeight = 20;
478
+
479
+ // Salto de página automático (dejando margen para el pie)
480
+ if (currentY + rowHeight > doc.page.height - 80) {
481
+ doc.addPage();
482
+ currentY = dibujarHeaderTabla(doc.y);
483
+ doc.font(this.config.fuenteBase).fontSize(this.config.fontSizePequeño).fillColor("black");
484
+ }
485
+
486
+ // Fondo alterno para las filas
487
+ if (index % 2 === 0) {
488
+ doc.rect(margin, currentY, availableWidth, rowHeight).fill(this.config.colorFondoAlterno);
489
+ }
490
+
491
+ doc.fillColor("black");
492
+ let cx = margin;
493
+ metadatos.forEach(col => {
494
+ let valor = fila[col.llave];
495
+ // Formatear si es salario o número de días
496
+ if (col.llave === 'Salario' && valor) {
497
+ valor = Number(valor).toLocaleString('es-CR', { style: 'currency', currency: 'CRC' });
498
+ } else if (col.llave === 'DiasOtorgados' && valor) {
499
+ valor = Number(valor).toFixed(1);
500
+ }
501
+
502
+ doc.text(String(valor || ""), cx + 5, currentY + 6, { width: col.ancho - 10, align: 'left' });
503
+ cx += col.ancho;
504
+ });
505
+
506
+ // Línea de separación de fila
507
+ doc.moveTo(margin, currentY + rowHeight).lineTo(pageWidth - margin, currentY + rowHeight).strokeColor(this.config.colorLinea).lineWidth(0.5).stroke();
508
+
509
+ currentY += rowHeight;
510
+ // Actualizar la posición global del cursor Y del documento
511
+ doc.y = currentY;
512
+ });
513
+
514
+ doc.moveDown(1);
515
+ }
516
+
517
+ generarSaltoDePagina(doc) {
518
+ // PDFKit generará automáticamente el encabezado de la nueva página debido
519
+ // al evento doc.on('pageAdded') que configuramos en crearPDFDinamico.
520
+ doc.addPage();
521
+ }
522
+
523
+ generarParrafoHTML(doc, html, opciones = {}) {
524
+ const margin = this.config.margin;
525
+ const pageWidth = doc.page.width;
526
+ const availableWidth = opciones.ancho || (pageWidth - 2 * margin);
527
+
528
+ // Mejorar la expresión regular para que mantenga los espacios en blanco
529
+ // y capture los tags sin romper las palabras
530
+ const tokens = String(html || "")
531
+ .replace(/\n/g, ' ') // Eliminar saltos de línea para que fluya como un párrafo
532
+ .split(/(<[a-z0-9/]+[^>]*>)/gi);
533
+
534
+ let isBold = false;
535
+ let isItalic = false;
536
+ let isUnderline = false;
537
+ let currentLink = null;
538
+
539
+ const fontSize = opciones.fontSize || this.config.fontSizeSubtitulo;
540
+ const align = opciones.align || 'justify';
541
+
542
+ // Determinar el último token de texto que no está vacío
543
+ let lastTextIndex = -1;
544
+ for (let i = tokens.length - 1; i >= 0; i--) {
545
+ const token = tokens[i];
546
+ if (!token.match(/^<.*>$/) && token.trim() !== "") {
547
+ lastTextIndex = i;
548
+ break;
549
+ }
550
+ }
551
+
552
+ doc.fillColor('black');
553
+
554
+ // Comprobamos si hay espacio suficiente en la página antes de empezar a dibujar
555
+ // Estimamos un alto de línea basado en el tamaño de fuente
556
+ const estimatedHeight = fontSize * 1.5;
557
+ if (doc.y + estimatedHeight > doc.page.height - doc.page.margins.bottom - 40) {
558
+ doc.addPage();
559
+ }
560
+
561
+ // Como pdfkit podría venir de un texto dibujado con coordenadas X y Y absolutas (que rompe el flujo interno de texto),
562
+ // definimos la posición X al margen izquierdo para iniciar el párrafo, manteniendo la coordenada Y actual.
563
+ doc.x = margin;
564
+
565
+ // Función auxiliar para aplicar el formato y escribir el buffer
566
+ const flushText = (token, isLast) => {
567
+ if (token === "") return;
568
+
569
+ let fontName = this.config.fuenteBase;
570
+ if (isBold && isItalic) fontName = this.config.fuenteNegritaCursiva;
571
+ else if (isBold) fontName = this.config.fuenteNegrita;
572
+ else if (isItalic) fontName = this.config.fuenteCursiva;
573
+
574
+ doc.font(fontName).fontSize(fontSize);
575
+
576
+ // Decidimos si usar 'continued'
577
+ // Es 'continued' a menos que sea el último fragmento de texto
578
+ const isContinued = !isLast;
579
+
580
+ // Para evitar que las palabras con diferente formato se peguen,
581
+ // añadimos explícitamente un espacio en blanco al final de cada fragmento
582
+ // si este no es el último y si el token en sí no termina ya en un espacio.
583
+ let textoParaImprimir = token;
584
+ if (isContinued && !textoParaImprimir.endsWith(' ')) {
585
+ textoParaImprimir += ' ';
586
+ }
587
+
588
+ // Comprobar espacio por cada fragmento para prevenir que `continued` desborde mal
589
+ if (doc.y + fontSize > doc.page.height - doc.page.margins.bottom - 20) {
590
+ // En PDFKit, hacer addPage() mientras hay 'continued' true a veces falla, pero ayuda forzarlo antes.
591
+ doc.text(' ', { continued: false }); // forzar fin de linea
592
+ doc.addPage();
593
+ doc.x = margin; // reposicionar en nueva pagina
594
+ }
595
+
596
+ // Si hay un link activo, usamos color azul y subrayado por defecto si no se especificó lo contrario
597
+ if (currentLink) {
598
+ doc.fillColor('blue');
599
+ } else {
600
+ doc.fillColor('black');
601
+ }
602
+
603
+ doc.text(textoParaImprimir, {
604
+ width: availableWidth,
605
+ continued: isContinued,
606
+ underline: isUnderline || (currentLink ? true : false),
607
+ link: currentLink,
608
+ align: align
609
+ });
610
+ };
611
+
612
+ for (let index = 0; index < tokens.length; index++) {
613
+ let token = tokens[index];
614
+
615
+ if (!token) continue;
616
+
617
+ const lowerToken = token.toLowerCase().trim();
618
+
619
+ // Tags admitidos
620
+ if (lowerToken === '<b>' || lowerToken === '<strong>') {
621
+ isBold = true;
622
+ continue;
623
+ }
624
+ if (lowerToken === '</b>' || lowerToken === '</strong>') {
625
+ isBold = false;
626
+ continue;
627
+ }
628
+ if (lowerToken === '<i>' || lowerToken === '<em>') {
629
+ isItalic = true;
630
+ continue;
631
+ }
632
+ if (lowerToken === '</i>' || lowerToken === '</em>') {
633
+ isItalic = false;
634
+ continue;
635
+ }
636
+ if (lowerToken === '<u>') {
637
+ isUnderline = true;
638
+ continue;
639
+ }
640
+ if (lowerToken === '</u>') {
641
+ isUnderline = false;
642
+ continue;
643
+ }
644
+ if (lowerToken.startsWith('<a')) {
645
+ const hrefMatch = token.match(/href=["']([^"']*)["']/i);
646
+ if (hrefMatch) {
647
+ currentLink = hrefMatch[1];
648
+ }
649
+ continue;
650
+ }
651
+ if (lowerToken === '</a>') {
652
+ currentLink = null;
653
+ continue;
654
+ }
655
+ if (lowerToken === '<br>' || lowerToken === '<br/>' || lowerToken === '<br />') {
656
+ // Si hay un salto de línea, rompemos el continued escribiendo un espacio final
657
+ doc.text(' ', { continued: false });
658
+ doc.moveDown(1);
659
+ continue;
660
+ }
661
+
662
+ // Si no es un tag, entonces es texto. Lo mandamos a imprimir.
663
+ if (!token.match(/^<.*>$/)) {
664
+ // En PDFKit el continued es complicado con espacios en los bordes de los tags
665
+ // Decodificamos entidades HTML comunes
666
+ token = token
667
+ .replace(/&nbsp;/g, ' ')
668
+ .replace(/&lt;/g, '<')
669
+ .replace(/&gt;/g, '>')
670
+ .replace(/&amp;/g, '&')
671
+ .replace(/&quot;/g, '"')
672
+ .replace(/&#39;/g, "'");
673
+
674
+ const isLast = index === lastTextIndex || index > lastTextIndex;
675
+ flushText(token, isLast);
676
+ }
677
+ }
678
+
679
+ doc.moveDown(1);
680
+ }
681
+
682
+ generarFirmas(doc, firmas) {
683
+ if (!firmas || !Array.isArray(firmas) || firmas.length === 0) return;
684
+
685
+ const margin = this.config.margin;
686
+ const pageWidth = doc.page.width;
687
+ const availableWidth = pageWidth - 2 * margin;
688
+ const signatureHeight = 75;
689
+ const gap = 10;
690
+ const logoWidth = 35;
691
+ const padding = 6;
692
+
693
+ // Dividimos las firmas en grupos de 3 (máximo por fila)
694
+ for (let i = 0; i < firmas.length; i += 3) {
695
+ const filaDeFirmas = firmas.slice(i, i + 3);
696
+ const cantidadEnFila = filaDeFirmas.length;
697
+
698
+ // Ancho total por bloque de firma según la cantidad en esta fila
699
+ const blockWidth = availableWidth / cantidadEnFila;
700
+
701
+ // Verificar si hay espacio en la página actual para esta fila
702
+ if (doc.y + signatureHeight > doc.page.height - 80) {
703
+ doc.addPage();
704
+ }
705
+
706
+ const startY = doc.y + 10;
707
+
708
+ filaDeFirmas.forEach((firma, index) => {
709
+ const blockX = margin + (index * blockWidth);
710
+
711
+ let innerWidth = blockWidth - gap;
712
+ let innerX = blockX + (gap / 2);
713
+
714
+ // Ajustar márgenes para los extremos
715
+ if (index === 0) {
716
+ innerX = blockX;
717
+ innerWidth = blockWidth - (gap / 2);
718
+ }
719
+ if (index === cantidadEnFila - 1) {
720
+ innerWidth = blockWidth - (gap / 2);
721
+ }
722
+ if (cantidadEnFila === 1) {
723
+ innerWidth = 240;
724
+ innerX = margin + (availableWidth / 2) - (innerWidth / 2);
725
+ }
726
+
727
+ // Dibujar fondo y borde
728
+ doc.save();
729
+ doc.fillColor("#FCFCFC").strokeColor("#EEEEEE").lineWidth(1);
730
+ doc.rect(innerX, startY, innerWidth, signatureHeight).fillAndStroke();
731
+ doc.restore();
732
+
733
+ let currentX = innerX + padding;
734
+
735
+ // Dibujar logo
736
+ try {
737
+ const rutaLogo = path.join(__dirname, "./LogoUTN.png");
738
+ // Centrar verticalmente el logo dentro del recuadro
739
+ const logoY = startY + (signatureHeight / 2) - 13;
740
+ doc.image(rutaLogo, currentX, logoY, { width: logoWidth });
741
+
742
+ // Línea vertical separadora
743
+ doc.save();
744
+ doc.moveTo(currentX + logoWidth + padding, startY + padding)
745
+ .lineTo(currentX + logoWidth + padding, startY + signatureHeight - padding)
746
+ .strokeColor("#CCCCCC")
747
+ .lineWidth(0.5)
748
+ .stroke();
749
+ doc.restore();
750
+
751
+ currentX += logoWidth + (padding * 2);
752
+ } catch (e) {
753
+ // Si falla el logo, currentX se mantiene igual para el texto
754
+ }
755
+
756
+ // Área para el texto
757
+ const textWidth = innerWidth - (currentX - innerX) - padding;
758
+ let textY = startY + padding + 1;
759
+
760
+ doc.fillColor("black");
761
+
762
+ // 1. Título
763
+ doc.fontSize(6).font(this.config.fuenteNegrita);
764
+ doc.text("Firma electrónica por:", currentX, textY, { width: textWidth, align: 'left' });
765
+ textY = doc.y + 1;
766
+
767
+ // 2. Nombre
768
+ doc.fontSize(6).font(this.config.fuenteBase);
769
+ doc.text(firma.Nombre || 'N/A', currentX, textY, { width: textWidth, align: 'left' });
770
+ textY = doc.y + 1;
771
+
772
+ // 3. Cédula
773
+ doc.text(`Cédula: ${firma.Identificacion || 'N/A'}`, currentX, textY, { width: textWidth, align: 'left' });
774
+ textY = doc.y + 1;
775
+
776
+ // 4. Instancia
777
+ doc.text(`Instancia: ${firma.Instancia || 'N/A'}`, currentX, textY, { width: textWidth, align: 'left' });
778
+ textY = doc.y + 1;
779
+
780
+ // 5. Fecha
781
+ doc.text(`Fecha: ${firma.Fecha || 'N/A'}`, currentX, textY, { width: textWidth, align: 'left' });
782
+ textY = doc.y + 1;
783
+
784
+ // 6. Motivo
785
+ doc.text(`Motivo: ${firma.Motivo || 'N/A'}`, currentX, textY, { width: textWidth, align: 'left' });
786
+ });
787
+
788
+ // Mover el cursor para la siguiente fila
789
+ doc.y = startY + signatureHeight + 10;
790
+ }
791
+
792
+ // Agregar un poco de margen después de todas las firmas
793
+ doc.y += 10;
794
+ }
795
+ }
796
+
797
+ module.exports = new ReportePDF();