diff --git a/nuxt4/app/composables/usePdfExport.ts b/nuxt4/app/composables/usePdfExport.ts new file mode 100644 index 0000000..118be38 --- /dev/null +++ b/nuxt4/app/composables/usePdfExport.ts @@ -0,0 +1,150 @@ +/** + * Composable para exportar formularios de catación a PDF + * Genera PDFs en formato EVC-IH01 (formulario físico de IHCAFE) + */ + +import { jsPDF } from 'jspdf' +import type { Muestra, SesionCatacion } from '~/types/catacion' +import { renderizarFormulario } from '~/utils/pdf' + +const FORMULARIOS_POR_PAGINA = 3 + +export const usePdfExport = () => { + const exportando = ref(false) + const error = ref(null) + + /** + * Exporta una muestra individual a PDF + */ + const exportarMuestra = async ( + muestra: Muestra, + nombreArchivo?: string + ): Promise => { + try { + exportando.value = true + error.value = null + + const doc = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'letter', + }) + + renderizarFormulario(doc, muestra, 0) + + const filename = + nombreArchivo || `catacion-${sanitizarNombre(muestra.nombre)}-${Date.now()}.pdf` + doc.save(filename) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Error al exportar PDF' + console.error('Error exportando PDF:', err) + throw err + } finally { + exportando.value = false + } + } + + /** + * Exporta toda la sesión a PDF (máximo 3 formularios por página) + */ + const exportarSesion = async ( + sesion: SesionCatacion, + nombreArchivo?: string + ): Promise => { + try { + exportando.value = true + error.value = null + + const doc = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'letter', + }) + + const muestras = sesion.muestras + + muestras.forEach((muestra, index) => { + const posicionEnPagina = index % FORMULARIOS_POR_PAGINA + + // Nueva página si es necesario (excepto primera) + if (index > 0 && posicionEnPagina === 0) { + doc.addPage() + } + + renderizarFormulario(doc, muestra, posicionEnPagina as 0 | 1 | 2) + }) + + const filename = nombreArchivo || `catacion-sesion-${sesion.fecha}.pdf` + doc.save(filename) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Error al exportar PDF' + console.error('Error exportando PDF de sesión:', err) + throw err + } finally { + exportando.value = false + } + } + + /** + * Exporta muestras seleccionadas a PDF + */ + const exportarMuestrasSeleccionadas = async ( + muestras: Muestra[], + nombreArchivo?: string + ): Promise => { + try { + exportando.value = true + error.value = null + + if (muestras.length === 0) { + throw new Error('No hay muestras seleccionadas para exportar') + } + + const doc = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'letter', + }) + + muestras.forEach((muestra, index) => { + const posicionEnPagina = index % FORMULARIOS_POR_PAGINA + + // Nueva página si es necesario (excepto primera) + if (index > 0 && posicionEnPagina === 0) { + doc.addPage() + } + + renderizarFormulario(doc, muestra, posicionEnPagina as 0 | 1 | 2) + }) + + const filename = nombreArchivo || `catacion-seleccion-${Date.now()}.pdf` + doc.save(filename) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Error al exportar PDF' + console.error('Error exportando PDFs seleccionados:', err) + throw err + } finally { + exportando.value = false + } + } + + return { + exportando: readonly(exportando), + error: readonly(error), + exportarMuestra, + exportarSesion, + exportarMuestrasSeleccionadas, + } +} + +/** + * Sanitiza un nombre para usar como nombre de archivo + */ +function sanitizarNombre(nombre: string): string { + return nombre + .toLowerCase() + .replace(/[^a-z0-9áéíóúñ]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50) +} diff --git a/nuxt4/app/pages/cata/sesion.vue b/nuxt4/app/pages/cata/sesion.vue index bc65a48..009fb03 100644 --- a/nuxt4/app/pages/cata/sesion.vue +++ b/nuxt4/app/pages/cata/sesion.vue @@ -51,11 +51,20 @@
+ + + + { }) } -// Exportar sesión +// Exportar sesión JSON const exportar = () => { const json = exportarSesion() if (!json) return @@ -465,6 +499,27 @@ const exportar = () => { mostrarMenu.value = false } +// Exportar sesión PDF +const handleExportarPdf = async () => { + if (!sesionActiva.value) return + + try { + await exportarSesionPdf(sesionActiva.value) + mostrarMenu.value = false + } catch (err) { + console.error('Error al exportar PDF:', err) + } +} + +// Exportar muestra individual a PDF +const handleExportarMuestraPdf = async (muestra: Muestra) => { + try { + await exportarMuestraPdf(muestra) + } catch (err) { + console.error('Error al exportar muestra PDF:', err) + } +} + // Confirmar eliminación const confirmarEliminar = () => { const confirmar = window.confirm( @@ -619,6 +674,52 @@ onMounted(() => { transform: scale(0.95); } +.boton-pdf { + flex-shrink: 0; + padding: 0.25rem; + background: none; + border: none; + color: var(--cata-primary); + cursor: pointer; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + opacity: 0.7; +} + +.boton-pdf:hover { + opacity: 1; + transform: scale(1.1); +} + +.boton-pdf:active { + transform: scale(0.95); +} + +.boton-pdf:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Loading spinner pequeño para botones */ +.loading-spinner-small { + width: 1.25rem; + height: 1.25rem; + border-radius: 9999px; + border: 2px solid color-mix(in srgb, var(--cata-primary) 20%, transparent); + border-top-color: var(--cata-primary); + animation: spin 1s linear infinite; +} + +.loading-spinner-tiny { + width: 1rem; + height: 1rem; + border-radius: 9999px; + border: 2px solid color-mix(in srgb, var(--cata-primary) 20%, transparent); + border-top-color: var(--cata-primary); + animation: spin 1s linear infinite; +} + /* Floating action button */ .floating-action { position: fixed; diff --git a/nuxt4/app/utils/pdf/index.ts b/nuxt4/app/utils/pdf/index.ts new file mode 100644 index 0000000..0f13c80 --- /dev/null +++ b/nuxt4/app/utils/pdf/index.ts @@ -0,0 +1,8 @@ +/** + * Módulo de exportación PDF para catación de café + * Exporta funciones para generar PDFs del formulario EVC-IH01 + */ + +export { renderizarFormulario } from './pdfFormulario' +export { PDF_CONFIG, calcularYInicio } from './pdfLayout' +export * from './pdfHelpers' diff --git a/nuxt4/app/utils/pdf/pdfFormulario.ts b/nuxt4/app/utils/pdf/pdfFormulario.ts new file mode 100644 index 0000000..7987355 --- /dev/null +++ b/nuxt4/app/utils/pdf/pdfFormulario.ts @@ -0,0 +1,590 @@ +/** + * Renderizado del formulario EVC-IH01 en PDF + * Replica el formato exacto del formulario físico de catación de café + */ + +import type { jsPDF } from 'jspdf' +import type { Muestra, Intensidades } from '~/types/catacion' +import { calcularSumatoriaAfectiva, calcularSCAA } from '~/types/catacion' +import { + PDF_CONFIG, + calcularYInicio, + PARAMETROS_INTENSIDAD, + CATEGORIAS_PDF, + SENSACIONES_PDF, + GUSTOS_PDF, + DEFECTOS_PDF, +} from './pdfLayout' +import { + dibujarCheckbox, + dibujarCheckboxConLabel, + dibujarCasillasTazas, + dibujarCeldaTexto, + dibujarRectangulo, + dibujarLineaHorizontal, + dibujarLineaVertical, + truncarTexto, +} from './pdfHelpers' + +/** + * Renderiza un formulario completo de catación + */ +export function renderizarFormulario( + doc: jsPDF, + muestra: Muestra, + posicion: 0 | 1 | 2 +): void { + const yBase = calcularYInicio(posicion) + const xBase = PDF_CONFIG.marginLeft + + // Borde exterior del formulario + dibujarRectangulo( + doc, + xBase, + yBase, + PDF_CONFIG.formWidth, + PDF_CONFIG.formHeight, + PDF_CONFIG.lineWidth.thick + ) + + // Renderizar cada sección + renderizarEncabezado(doc, xBase, yBase, muestra) + renderizarTablaIntensidades(doc, xBase, yBase + 10, muestra) + renderizarSeccionFraganciaAroma(doc, xBase + 48, yBase + 10, muestra) + renderizarSeccionSabores(doc, xBase + 48, yBase + 32, muestra) + renderizarSeccionSensacionGustos(doc, xBase, yBase + 46, muestra) + renderizarSeccionTazasDefectos(doc, xBase, yBase + 58, muestra) + renderizarSeccionOtrasNotas(doc, xBase, yBase + 70, muestra) + renderizarSeccionSubTotal(doc, xBase, yBase + 78, muestra) +} + +/** + * Renderiza el encabezado del formulario + */ +function renderizarEncabezado( + doc: jsPDF, + x: number, + y: number, + muestra: Muestra +): void { + const width = PDF_CONFIG.formWidth + + // Línea separadora del encabezado + dibujarLineaHorizontal(doc, x, y + 10, x + width, PDF_CONFIG.lineWidth.normal) + + // Título principal + doc.setFontSize(PDF_CONFIG.fontSize.title) + doc.setFont('helvetica', 'bold') + doc.text('EVALUACION DEL VALOR DEL CAFE / DESCRIPTIVA-AFECTIVA', x + 2, y + 4) + + // Código EVC-IH01 + doc.setFontSize(PDF_CONFIG.fontSize.header) + doc.text('EVC-IH01', x + width - 20, y + 4) + + // Línea separadora después del título + dibujarLineaHorizontal(doc, x, y + 5.5, x + width, PDF_CONFIG.lineWidth.thin) + + // MUESTRA + doc.setFontSize(PDF_CONFIG.fontSize.body) + doc.setFont('helvetica', 'bold') + doc.text('MUESTRA:', x + 2, y + 8.5) + + doc.setFont('helvetica', 'normal') + const nombreTruncado = truncarTexto(doc, muestra.nombre, 40, PDF_CONFIG.fontSize.body) + doc.text(nombreTruncado, x + 20, y + 8.5) +} + +/** + * Renderiza la tabla de intensidades (lado izquierdo) + */ +function renderizarTablaIntensidades( + doc: jsPDF, + x: number, + y: number, + muestra: Muestra +): void { + const colWidths = { + parametro: 22, + descriptiva: 12, + afectiva: 12, + } + const rowHeight = 3.5 + const tableWidth = colWidths.parametro + colWidths.descriptiva + colWidths.afectiva + + // Borde de la tabla + dibujarRectangulo(doc, x, y, tableWidth, rowHeight * 10, PDF_CONFIG.lineWidth.normal) + + // Headers + doc.setFontSize(PDF_CONFIG.fontSize.tiny) + doc.setFont('helvetica', 'bold') + + // Header: Intensidad + dibujarCeldaTexto(doc, x, y, colWidths.parametro, rowHeight, 'Intensidad', { + fontSize: PDF_CONFIG.fontSize.tiny, + fontStyle: 'bold', + }) + + // Subheaders de intensidad + doc.setFontSize(PDF_CONFIG.fontSize.tiny - 0.5) + doc.text('Parámetro', x + 1, y + rowHeight + 2.5) + + // Header: Descriptiva + dibujarCeldaTexto( + doc, + x + colWidths.parametro, + y, + colWidths.descriptiva, + rowHeight * 2, + '', + { border: true } + ) + doc.setFontSize(PDF_CONFIG.fontSize.tiny) + doc.text('Descriptiva', x + colWidths.parametro + 1, y + 3) + doc.text('(1 al 15)', x + colWidths.parametro + 2, y + 6) + + // Header: Afectiva + dibujarCeldaTexto( + doc, + x + colWidths.parametro + colWidths.descriptiva, + y, + colWidths.afectiva, + rowHeight * 2, + '', + { border: true } + ) + doc.text('Afectiva', x + colWidths.parametro + colWidths.descriptiva + 1.5, y + 3) + doc.text('(1 al 9)', x + colWidths.parametro + colWidths.descriptiva + 2.5, y + 6) + + // Línea horizontal después de headers + dibujarLineaHorizontal(doc, x, y + rowHeight * 2, x + tableWidth) + + // Filas de datos + const intensidades = muestra.intensidades + let currentY = y + rowHeight * 2 + + PARAMETROS_INTENSIDAD.forEach((param) => { + const intensidad = intensidades[param.key as keyof Intensidades] + + // Parámetro + doc.setFontSize(PDF_CONFIG.fontSize.tiny) + doc.setFont('helvetica', 'normal') + doc.text(param.label, x + 1, currentY + 2.5) + + // Valor descriptiva + if (intensidad.descriptiva !== null) { + doc.text( + String(intensidad.descriptiva), + x + colWidths.parametro + colWidths.descriptiva / 2 - 1, + currentY + 2.5 + ) + } + + // Valor afectiva + if (intensidad.afectiva !== null) { + doc.text( + String(intensidad.afectiva), + x + colWidths.parametro + colWidths.descriptiva + colWidths.afectiva / 2 - 1, + currentY + 2.5 + ) + } + + // Línea separadora + dibujarLineaHorizontal(doc, x, currentY + rowHeight, x + tableWidth, PDF_CONFIG.lineWidth.thin) + + currentY += rowHeight + }) + + // Líneas verticales de la tabla + dibujarLineaVertical(doc, x + colWidths.parametro, y, y + rowHeight * 10) + dibujarLineaVertical( + doc, + x + colWidths.parametro + colWidths.descriptiva, + y, + y + rowHeight * 10 + ) +} + +/** + * Renderiza la sección de Fragancia-Aroma + */ +function renderizarSeccionFraganciaAroma( + doc: jsPDF, + x: number, + y: number, + muestra: Muestra +): void { + const width = 151.9 // Ancho total de la sección derecha + const colWidth = width / 2 + + // Borde de la sección + dibujarRectangulo(doc, x, y, width, 22, PDF_CONFIG.lineWidth.normal) + + // Header + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'bold') + doc.text('Fragancia - Aroma', x + 2, y + 3) + + // Línea después del header + dibujarLineaHorizontal(doc, x, y + 4.5, x + width, PDF_CONFIG.lineWidth.thin) + + // Header columna Notas + doc.text('Notas', x + colWidth + 2, y + 3) + + // Línea vertical divisoria + dibujarLineaVertical(doc, x + colWidth, y, y + 22) + + // Checkboxes columna izquierda + let checkY = y + 6 + const checkSpacing = 1.8 + + // Determinar qué está seleccionado + const categoriasSeleccionadas = muestra.fraganciaAromaNotas.categorias + const subcategoriasSeleccionadas = muestra.fraganciaAromaNotas.subcategorias + + // Columna izquierda de checkboxes (primera mitad de categorías) + const categoriasIzq = CATEGORIAS_PDF.columnaIzquierda.slice(0, 6) + categoriasIzq.forEach((cat) => { + const isChecked = + categoriasSeleccionadas.includes(cat.key as any) || + subcategoriasSeleccionadas.includes(cat.key) + + dibujarCheckboxConLabel(doc, x + 2, checkY, cat.label, isChecked, cat.indent) + checkY += checkSpacing + }) + + // Continuar en columna media + checkY = y + 6 + const categoriasMed = CATEGORIAS_PDF.columnaIzquierda.slice(6) + categoriasMed.forEach((cat) => { + const isChecked = + categoriasSeleccionadas.includes(cat.key as any) || + subcategoriasSeleccionadas.includes(cat.key) + + dibujarCheckboxConLabel(doc, x + 28, checkY, cat.label, isChecked, cat.indent) + checkY += checkSpacing + }) + + // Columna derecha de checkboxes (Notas) + checkY = y + 6 + const categoriasDer = CATEGORIAS_PDF.columnaDerecha.slice(0, 6) + categoriasDer.forEach((cat) => { + const isChecked = + categoriasSeleccionadas.includes(cat.key as any) || + subcategoriasSeleccionadas.includes(cat.key) + + dibujarCheckboxConLabel(doc, x + colWidth + 2, checkY, cat.label, isChecked, cat.indent) + checkY += checkSpacing + }) + + // Segunda columna de Notas + checkY = y + 6 + const categoriasDer2 = CATEGORIAS_PDF.columnaDerecha.slice(6) + categoriasDer2.forEach((cat) => { + const isChecked = + categoriasSeleccionadas.includes(cat.key as any) || + subcategoriasSeleccionadas.includes(cat.key) + + dibujarCheckboxConLabel(doc, x + colWidth + 35, checkY, cat.label, isChecked, cat.indent) + checkY += checkSpacing + }) + + // Nota específica + if (muestra.fraganciaAromaNotas.notaEspecifica) { + doc.setFontSize(PDF_CONFIG.fontSize.tiny) + doc.setFont('helvetica', 'normal') + const notaTruncada = truncarTexto( + doc, + muestra.fraganciaAromaNotas.notaEspecifica, + colWidth - 10, + PDF_CONFIG.fontSize.tiny + ) + doc.text(notaTruncada, x + 2, y + 20) + } +} + +/** + * Renderiza la sección de Sabores + */ +function renderizarSeccionSabores( + doc: jsPDF, + x: number, + y: number, + muestra: Muestra +): void { + const width = 151.9 + const colWidth = width / 2 + + // Borde de la sección + dibujarRectangulo(doc, x, y, width, 14, PDF_CONFIG.lineWidth.normal) + + // Header + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'bold') + doc.text('Sabores', x + 2, y + 3) + + // Header Notas + doc.text('Notas', x + colWidth + 2, y + 3) + + // Línea después del header + dibujarLineaHorizontal(doc, x, y + 4.5, x + width, PDF_CONFIG.lineWidth.thin) + + // Línea vertical divisoria + dibujarLineaVertical(doc, x + colWidth, y, y + 14) + + // Checkboxes (mismas categorías que fragancia) + let checkY = y + 6 + const checkSpacing = 1.6 + + const categoriasSeleccionadas = muestra.saborNotas.categorias + const subcategoriasSeleccionadas = muestra.saborNotas.subcategorias + + // Columna izquierda (primeras 4) + const categoriasIzq = CATEGORIAS_PDF.columnaIzquierda.slice(0, 4) + categoriasIzq.forEach((cat) => { + const isChecked = + categoriasSeleccionadas.includes(cat.key as any) || + subcategoriasSeleccionadas.includes(cat.key) + + dibujarCheckboxConLabel(doc, x + 2, checkY, cat.label, isChecked, cat.indent) + checkY += checkSpacing + }) + + // Columna media + checkY = y + 6 + const categoriasMed = CATEGORIAS_PDF.columnaIzquierda.slice(4, 8) + categoriasMed.forEach((cat) => { + const isChecked = + categoriasSeleccionadas.includes(cat.key as any) || + subcategoriasSeleccionadas.includes(cat.key) + + dibujarCheckboxConLabel(doc, x + 28, checkY, cat.label, isChecked, cat.indent) + checkY += checkSpacing + }) + + // Columna derecha + checkY = y + 6 + const categoriasDer = CATEGORIAS_PDF.columnaDerecha.slice(0, 4) + categoriasDer.forEach((cat) => { + const isChecked = + categoriasSeleccionadas.includes(cat.key as any) || + subcategoriasSeleccionadas.includes(cat.key) + + dibujarCheckboxConLabel(doc, x + colWidth + 2, checkY, cat.label, isChecked, cat.indent) + checkY += checkSpacing + }) + + // Segunda columna derecha + checkY = y + 6 + const categoriasDer2 = CATEGORIAS_PDF.columnaDerecha.slice(4, 8) + categoriasDer2.forEach((cat) => { + const isChecked = + categoriasSeleccionadas.includes(cat.key as any) || + subcategoriasSeleccionadas.includes(cat.key) + + dibujarCheckboxConLabel(doc, x + colWidth + 35, checkY, cat.label, isChecked, cat.indent) + checkY += checkSpacing + }) + + // Nota específica de sabor + if (muestra.saborNotas.notaEspecifica) { + doc.setFontSize(PDF_CONFIG.fontSize.tiny) + doc.setFont('helvetica', 'normal') + const notaTruncada = truncarTexto( + doc, + muestra.saborNotas.notaEspecifica, + colWidth - 10, + PDF_CONFIG.fontSize.tiny + ) + doc.text(notaTruncada, x + 2, y + 12.5) + } +} + +/** + * Renderiza la sección de Sensación en boca y Gustos predominantes + */ +function renderizarSeccionSensacionGustos( + doc: jsPDF, + x: number, + y: number, + muestra: Muestra +): void { + const width = PDF_CONFIG.formWidth + + // Borde de la sección + dibujarRectangulo(doc, x, y, width, 12, PDF_CONFIG.lineWidth.normal) + + // Sección Sensación en boca + const sensacionWidth = width * 0.6 + + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'bold') + doc.text('Sensación en boca', x + 2, y + 3) + + // Línea divisoria vertical + dibujarLineaVertical(doc, x + sensacionWidth, y, y + 12) + + // Checkboxes de sensación + let checkY = y + 5 + const checkSpacing = 2.2 + let checkX = x + 2 + + SENSACIONES_PDF.forEach((sens, index) => { + const isChecked = muestra.sensacionEnBoca === sens.key + + // Ajustar posición para layout de 2 filas + if (index === 3) { + checkY = y + 5 + checkX = x + 60 + } else if (index > 0 && index < 3) { + checkY += checkSpacing + } else if (index > 3) { + checkY += checkSpacing + } + + // Usar label corto para ahorrar espacio + const labelCorto = + sens.key === 'Deja seca la boca' + ? 'Deja seca boca' + : sens.key === 'Áspero' + ? 'Áspero' + : sens.key + + dibujarCheckboxConLabel(doc, checkX, checkY, labelCorto, isChecked) + }) + + // Sección Gustos predominantes + doc.setFont('helvetica', 'bold') + doc.text('Gustos predominantes (2)', x + sensacionWidth + 2, y + 3) + + // Checkboxes de gustos + checkY = y + 5 + checkX = x + sensacionWidth + 2 + + GUSTOS_PDF.forEach((gusto, index) => { + const isChecked = muestra.gustosPredominantes.includes(gusto.key as any) + + if (index === 3) { + checkY = y + 5 + checkX = x + sensacionWidth + 35 + } else if (index > 0 && index < 3) { + checkY += checkSpacing + } else if (index > 3) { + checkY += checkSpacing + } + + dibujarCheckboxConLabel(doc, checkX, checkY, gusto.label, isChecked) + }) +} + +/** + * Renderiza la sección de Tazas y Defectos + */ +function renderizarSeccionTazasDefectos( + doc: jsPDF, + x: number, + y: number, + muestra: Muestra +): void { + const width = PDF_CONFIG.formWidth + + // Borde de la sección + dibujarRectangulo(doc, x, y, width, 12, PDF_CONFIG.lineWidth.normal) + + // Tazas NO Uniformes + dibujarCasillasTazas(doc, x + 2, y + 2, muestra.tazasNoUniformes, 'Tazas NO Uniformes:') + + // Tazas Defectuosas + dibujarCasillasTazas(doc, x + 55, y + 2, muestra.tazasDefectuosas, 'Tazas defectuosas:') + + // Defectos + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'bold') + doc.text('Defecto (de beberse):', x + 110, y + 3) + + let checkX = x + 150 + DEFECTOS_PDF.forEach((defecto) => { + const isChecked = muestra.defecto === defecto.key + dibujarCheckboxConLabel(doc, checkX, y + 5, defecto.label, isChecked) + checkX += 18 + }) +} + +/** + * Renderiza la sección de Otras Notas + */ +function renderizarSeccionOtrasNotas( + doc: jsPDF, + x: number, + y: number, + muestra: Muestra +): void { + const width = PDF_CONFIG.formWidth + + // Borde de la sección + dibujarRectangulo(doc, x, y, width, 8, PDF_CONFIG.lineWidth.normal) + + // Label + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'bold') + doc.text('OTRAS NOTAS:', x + 2, y + 4) + + // Valor + if (muestra.otrasNotas) { + doc.setFont('helvetica', 'normal') + const notaTruncada = truncarTexto( + doc, + muestra.otrasNotas, + width - 35, + PDF_CONFIG.fontSize.small + ) + doc.text(notaTruncada, x + 30, y + 4) + } + + // Línea para escribir + dibujarLineaHorizontal(doc, x + 30, y + 5, x + width - 2, PDF_CONFIG.lineWidth.thin) +} + +/** + * Renderiza la sección de Sub Total + */ +function renderizarSeccionSubTotal( + doc: jsPDF, + x: number, + y: number, + muestra: Muestra +): void { + const width = PDF_CONFIG.formWidth + + // Borde de la sección + dibujarRectangulo(doc, x, y, width, 7, PDF_CONFIG.lineWidth.normal) + + // Cálculos + const sumatoriaAfectiva = calcularSumatoriaAfectiva(muestra) + const scaa = calcularSCAA(muestra) + + // Daño: 6pts por cada taza defectuosa (por definición también es no uniforme) + const dano = muestra.tazasDefectuosas.length * 6 + + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'bold') + doc.text('SUB TOTAL:', x + 2, y + 4) + + // Valores + const valores = [ + { label: 'Sumatoria Afectiva:', valor: sumatoriaAfectiva.toString() }, + { label: 'Total (100%):', valor: scaa.toFixed(2) }, + { label: 'Daño (6pto Taza):', valor: dano.toString() }, + { label: 'Total (100%) - Daño:', valor: (scaa - dano).toFixed(2) }, + ] + + let currentX = x + 28 + valores.forEach((item) => { + doc.setFont('helvetica', 'normal') + doc.text(item.label, currentX, y + 4) + currentX += doc.getTextWidth(item.label) + 1 + + doc.setFont('helvetica', 'bold') + doc.text(item.valor, currentX, y + 4) + currentX += doc.getTextWidth(item.valor) + 5 + }) +} diff --git a/nuxt4/app/utils/pdf/pdfHelpers.ts b/nuxt4/app/utils/pdf/pdfHelpers.ts new file mode 100644 index 0000000..f2b06bd --- /dev/null +++ b/nuxt4/app/utils/pdf/pdfHelpers.ts @@ -0,0 +1,263 @@ +/** + * Funciones auxiliares para dibujar elementos en el PDF + */ + +import type { jsPDF } from 'jspdf' +import { PDF_CONFIG } from './pdfLayout' + +/** + * Dibuja un checkbox (marcado o sin marcar) + */ +export function dibujarCheckbox( + doc: jsPDF, + x: number, + y: number, + checked: boolean = false, + size: number = PDF_CONFIG.checkboxSize +): void { + doc.setDrawColor(0) + doc.setLineWidth(PDF_CONFIG.lineWidth.normal) + doc.rect(x, y, size, size) + + if (checked) { + // Dibujar X dentro del checkbox + const padding = 0.4 + doc.line(x + padding, y + padding, x + size - padding, y + size - padding) + doc.line(x + size - padding, y + padding, x + padding, y + size - padding) + } +} + +/** + * Dibuja un checkbox con label + */ +export function dibujarCheckboxConLabel( + doc: jsPDF, + x: number, + y: number, + label: string, + checked: boolean = false, + indent: boolean = false +): number { + const checkboxSize = PDF_CONFIG.checkboxSize + const xOffset = indent ? 2 : 0 + + dibujarCheckbox(doc, x + xOffset, y, checked, checkboxSize) + + doc.setFontSize(PDF_CONFIG.fontSize.tiny) + doc.setFont('helvetica', 'normal') + doc.text(label, x + xOffset + checkboxSize + 1, y + checkboxSize - 0.5) + + // Retorna el ancho total usado + return xOffset + checkboxSize + 1 + doc.getTextWidth(label) +} + +/** + * Dibuja las 5 casillas de tazas con números + */ +export function dibujarCasillasTazas( + doc: jsPDF, + x: number, + y: number, + tazasSeleccionadas: number[], + label: string +): void { + const checkboxSize = PDF_CONFIG.checkboxSize + const spacing = checkboxSize + 1.5 + + // Label + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'bold') + doc.text(label, x, y) + + // Casillas 1-5 + const startX = x + const startY = y + 2 + + for (let i = 1; i <= 5; i++) { + const checkX = startX + (i - 1) * spacing + const isSelected = tazasSeleccionadas.includes(i) + + dibujarCheckbox(doc, checkX, startY, isSelected, checkboxSize) + + // Número debajo de la casilla + doc.setFontSize(PDF_CONFIG.fontSize.tiny) + doc.setFont('helvetica', 'normal') + doc.text(String(i), checkX + checkboxSize / 2 - 0.5, startY + checkboxSize + 2) + } +} + +/** + * Dibuja un rectángulo con texto centrado + */ +export function dibujarCeldaTexto( + doc: jsPDF, + x: number, + y: number, + width: number, + height: number, + texto: string, + opciones: { + fontSize?: number + fontStyle?: 'normal' | 'bold' + align?: 'left' | 'center' | 'right' + border?: boolean + backgroundColor?: string + } = {} +): void { + const { + fontSize = PDF_CONFIG.fontSize.body, + fontStyle = 'normal', + align = 'center', + border = true, + backgroundColor, + } = opciones + + // Fondo si se especifica + if (backgroundColor) { + doc.setFillColor(backgroundColor) + doc.rect(x, y, width, height, 'F') + } + + // Borde + if (border) { + doc.setDrawColor(0) + doc.setLineWidth(PDF_CONFIG.lineWidth.thin) + doc.rect(x, y, width, height) + } + + // Texto + doc.setFontSize(fontSize) + doc.setFont('helvetica', fontStyle) + + let textX = x + 1 + if (align === 'center') { + textX = x + width / 2 + } else if (align === 'right') { + textX = x + width - 1 + } + + doc.text(texto, textX, y + height / 2 + fontSize / 4, { + align, + }) +} + +/** + * Dibuja una línea horizontal + */ +export function dibujarLineaHorizontal( + doc: jsPDF, + x1: number, + y: number, + x2: number, + grosor: number = PDF_CONFIG.lineWidth.normal +): void { + doc.setDrawColor(0) + doc.setLineWidth(grosor) + doc.line(x1, y, x2, y) +} + +/** + * Dibuja una línea vertical + */ +export function dibujarLineaVertical( + doc: jsPDF, + x: number, + y1: number, + y2: number, + grosor: number = PDF_CONFIG.lineWidth.normal +): void { + doc.setDrawColor(0) + doc.setLineWidth(grosor) + doc.line(x, y1, x, y2) +} + +/** + * Dibuja un campo de texto con label y línea para escribir + */ +export function dibujarCampoTexto( + doc: jsPDF, + x: number, + y: number, + label: string, + valor: string | null, + anchoLinea: number = 30 +): void { + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'bold') + doc.text(label, x, y) + + const labelWidth = doc.getTextWidth(label) + + // Línea para escribir + dibujarLineaHorizontal(doc, x + labelWidth + 1, y + 0.5, x + labelWidth + 1 + anchoLinea) + + // Valor si existe + if (valor) { + doc.setFont('helvetica', 'normal') + doc.text(valor, x + labelWidth + 2, y) + } +} + +/** + * Dibuja texto multilínea con word wrap + */ +export function dibujarTextoMultilinea( + doc: jsPDF, + x: number, + y: number, + texto: string, + maxWidth: number, + lineHeight: number = 3 +): number { + doc.setFontSize(PDF_CONFIG.fontSize.small) + doc.setFont('helvetica', 'normal') + + const lines = doc.splitTextToSize(texto, maxWidth) + let currentY = y + + for (const line of lines) { + doc.text(line, x, currentY) + currentY += lineHeight + } + + return currentY - y // Retorna la altura total usada +} + +/** + * Dibuja un rectángulo con bordes + */ +export function dibujarRectangulo( + doc: jsPDF, + x: number, + y: number, + width: number, + height: number, + grosor: number = PDF_CONFIG.lineWidth.normal +): void { + doc.setDrawColor(0) + doc.setLineWidth(grosor) + doc.rect(x, y, width, height) +} + +/** + * Trunca texto si excede el ancho máximo + */ +export function truncarTexto( + doc: jsPDF, + texto: string, + maxWidth: number, + fontSize: number +): string { + doc.setFontSize(fontSize) + + if (doc.getTextWidth(texto) <= maxWidth) { + return texto + } + + let truncado = texto + while (doc.getTextWidth(truncado + '...') > maxWidth && truncado.length > 0) { + truncado = truncado.slice(0, -1) + } + + return truncado + '...' +} diff --git a/nuxt4/app/utils/pdf/pdfLayout.ts b/nuxt4/app/utils/pdf/pdfLayout.ts new file mode 100644 index 0000000..dbaca7b --- /dev/null +++ b/nuxt4/app/utils/pdf/pdfLayout.ts @@ -0,0 +1,223 @@ +/** + * Constantes y configuración del layout para el formulario PDF EVC-IH01 + * Tamaño: Carta (Letter) - 8.5" x 11" (215.9mm x 279.4mm) + * Máximo 3 formularios por página + */ + +export const PDF_CONFIG = { + // Página Carta (Letter) + pageWidth: 215.9, // mm (8.5") + pageHeight: 279.4, // mm (11") + + // Márgenes + marginTop: 8, + marginBottom: 8, + marginLeft: 8, + marginRight: 8, + + // Formulario individual (3 por página) + formHeight: 85, // ~85mm cada formulario + formWidth: 199.9, // pageWidth - marginLeft - marginRight + formSpacing: 2, // Espacio entre formularios + + // Tipografía + fontSize: { + title: 9, + header: 7, + body: 6, + small: 5, + tiny: 4.5, + }, + + // Colores + colors: { + black: '#000000', + gray: '#666666', + lightGray: '#CCCCCC', + }, + + // Grosor de líneas + lineWidth: { + thin: 0.1, + normal: 0.2, + thick: 0.4, + }, + + // Tamaño de checkbox + checkboxSize: 2.5, + checkboxSpacing: 0.8, +} as const + +/** + * Calcula la posición Y inicial para un formulario según su posición en la página + * @param posicion - Posición del formulario (0, 1, o 2) + * @returns Coordenada Y inicial en mm + */ +export function calcularYInicio(posicion: 0 | 1 | 2): number { + const { marginTop, formHeight, formSpacing } = PDF_CONFIG + return marginTop + posicion * (formHeight + formSpacing) +} + +/** + * Secciones del formulario con sus posiciones relativas + */ +export const FORM_SECTIONS = { + // Encabezado + header: { + y: 0, + height: 10, + }, + // Tabla de intensidades (lado izquierdo) + intensidades: { + x: 0, + y: 10, + width: 48, + height: 32, + }, + // Fragancia-Aroma (centro-derecha superior) + fraganciaAroma: { + x: 48, + y: 10, + width: 76, + height: 18, + }, + // Notas Fragancia-Aroma (derecha superior) + notasFragancia: { + x: 124, + y: 10, + width: 75.9, + height: 18, + }, + // Sabores (centro-derecha medio) + sabores: { + x: 48, + y: 28, + width: 76, + height: 14, + }, + // Notas Sabores (derecha medio) + notasSabor: { + x: 124, + y: 28, + width: 75.9, + height: 14, + }, + // Sensación en boca y gustos + sensacionGustos: { + x: 48, + y: 42, + width: 151.9, + height: 12, + }, + // Tazas y defectos + tazasDefectos: { + x: 0, + y: 54, + width: 199.9, + height: 12, + }, + // Otras notas + otrasNotas: { + x: 0, + y: 66, + width: 199.9, + height: 8, + }, + // Sub total + subTotal: { + x: 0, + y: 74, + width: 199.9, + height: 10, + }, +} as const + +/** + * Nombres de los parámetros de intensidad para la tabla + */ +export const PARAMETROS_INTENSIDAD = [ + { key: 'fragancia', label: 'Fragancia' }, + { key: 'aroma', label: 'Aroma' }, + { key: 'sabor', label: 'Sabor' }, + { key: 'saborResidual', label: 'Sabor Residual' }, + { key: 'acidez', label: 'Acidez' }, + { key: 'dulzor', label: 'Dulzor' }, + { key: 'sensacionBoca', label: 'Sensación Boca' }, + { key: 'impresionGlobal', label: 'Impresión Global' }, +] as const + +/** + * Tipo para categoría de nota en PDF + */ +export interface CategoriaPdf { + key: string + label: string + indent?: boolean +} + +/** + * Categorías de notas para el formulario PDF + * Columna izquierda y derecha de checkboxes + */ +export const CATEGORIAS_PDF: { + columnaIzquierda: CategoriaPdf[] + columnaDerecha: CategoriaPdf[] +} = { + columnaIzquierda: [ + { key: 'Floral', label: 'Floral' }, + { key: 'Afrutado', label: 'Afrutado' }, + { key: 'Bayas', label: 'Bayas', indent: true }, + { key: 'Ácido', label: 'Ácido', indent: true }, + { key: 'Frutas Deshidratadas', label: 'Frutas Deshid.', indent: true }, + { key: 'Fermentado', label: 'Fermentado', indent: true }, + { key: 'Cítricos', label: 'Cítricos' }, + { key: 'Verde Vegetal', label: 'Verde/Vegetal' }, + { key: 'Químico', label: 'Químico' }, + { key: 'Humedad/Tierra', label: 'Humedad/Tierra' }, + { key: 'Madera', label: 'Madera' }, + ], + columnaDerecha: [ + { key: 'Tostado', label: 'Tostado' }, + { key: 'Cereal', label: 'Cereal', indent: true }, + { key: 'Quemado', label: 'Quemado', indent: true }, + { key: 'Tabaco', label: 'Tabaco', indent: true }, + { key: 'Nueces/Cacao', label: 'Nueces/Cacao' }, + { key: 'Nueces', label: 'Nueces', indent: true }, + { key: 'Cacao', label: 'Cacao', indent: true }, + { key: 'Especias', label: 'Especias' }, + { key: 'Dulce', label: 'Dulce' }, + { key: 'Vainilla', label: 'Vainilla', indent: true }, + { key: 'Azúcar Morena', label: 'Azúcar Morena', indent: true }, + ], +} + +/** + * Sensaciones en boca para el formulario PDF + */ +export const SENSACIONES_PDF = [ + { key: 'Áspero', label: 'Áspero (Arenoso, Rugoso, Rasposo)' }, + { key: 'Aceitoso', label: 'Aceitoso' }, + { key: 'Metálico', label: 'Metálico' }, + { key: 'Suave', label: 'Suave (Aterciopelado, Sedoso)' }, + { key: 'Deja seca la boca', label: 'Deja seca la boca (Astringente)' }, +] as const + +/** + * Gustos predominantes para el formulario PDF + */ +export const GUSTOS_PDF = [ + { key: 'Salado', label: 'Salado' }, + { key: 'Amargo', label: 'Amargo' }, + { key: 'Ácido', label: 'Ácido' }, + { key: 'Dulce', label: 'Dulce' }, + { key: 'Umami', label: 'Umami' }, +] as const + +/** + * Defectos para el formulario PDF + */ +export const DEFECTOS_PDF = [ + { key: 'Mohoso', label: 'Mohoso' }, + { key: 'Fenólico', label: 'Fenólico' }, + { key: 'Papa', label: 'Papa' }, +] as const diff --git a/nuxt4/package-lock.json b/nuxt4/package-lock.json index 827dedc..f39fe91 100644 --- a/nuxt4/package-lock.json +++ b/nuxt4/package-lock.json @@ -14,6 +14,7 @@ "@nuxt/ui": "^4.0.1", "better-sqlite3": "^12.4.1", "eslint": "^9.37.0", + "jspdf": "^3.0.4", "nuxt": "^4.1.3", "typescript": "^5.9.3", "vue": "^3.5.22", @@ -1692,7 +1693,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -6246,12 +6246,25 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/parse-path": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz", "integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -6262,7 +6275,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -7892,6 +7905,16 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8324,6 +8347,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -8917,6 +8960,18 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", @@ -9045,6 +9100,16 @@ "postcss": "^8.0.9" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -9586,6 +9651,16 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -10880,6 +10955,23 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-png/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -10923,6 +11015,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -12060,6 +12158,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -12266,6 +12378,12 @@ "node": ">= 0.4" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ioredis": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.1.tgz", @@ -13329,6 +13447,23 @@ "node": ">=0.10.0" } }, + "node_modules/jspdf": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", + "integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -15977,6 +16112,13 @@ "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16740,6 +16882,16 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -16924,6 +17076,13 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", @@ -17407,6 +17566,16 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.52.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", @@ -18185,6 +18354,16 @@ "node": ">=12.0.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -18574,6 +18753,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/svgo": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", @@ -18819,6 +19008,16 @@ "b4a": "^1.6.4" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -19888,6 +20087,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vaul-vue": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/vaul-vue/-/vaul-vue-0.4.1.tgz", diff --git a/nuxt4/package.json b/nuxt4/package.json index 84f99e2..794f5cc 100644 --- a/nuxt4/package.json +++ b/nuxt4/package.json @@ -17,6 +17,7 @@ "@nuxt/ui": "^4.0.1", "better-sqlite3": "^12.4.1", "eslint": "^9.37.0", + "jspdf": "^3.0.4", "nuxt": "^4.1.3", "typescript": "^5.9.3", "vue": "^3.5.22",