feat: Agregar exportación PDF del formulario de catación EVC-IH01
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m51s

- Implementar módulo de generación PDF con jsPDF
- Crear composable usePdfExport para exportar muestras y sesiones
- Añadir botones de exportación PDF en header y por muestra
- Replicar layout exacto del formulario físico IHCAFE
- Soportar máximo 3 formularios por hoja carta
This commit is contained in:
2025-11-24 17:20:49 -06:00
parent cbea6d2885
commit 701d0c8fdb
8 changed files with 1550 additions and 5 deletions

View File

@@ -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'

View File

@@ -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
})
}

View File

@@ -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 + '...'
}

View File

@@ -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