Resumen del fix:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

loadImageBase64 ahora retorna { base64, pixelWidth, pixelHeight } (320×210px tras el resize) en vez de solo el string.
PdfHeaderOptions agrega logoPixelWidth/logoPixelHeight.
drawSharedHeader: logo a 15mm de ancho con alto calculado proporcionalmente desde las dimensiones reales (≈9.8mm, ratio real ~1.53:1, no el 3.56:1 hardcodeado anterior). El logo se posiciona arriba (MARGIN - 1), y processName + fecha bajan a la línea siguiente sin superponerse. clientName baja una línea adicional cuando hay logo (para no chocar con la fecha), ajustando y antes del border-bottom.
Fallback a texto "InQ ROI" intacto cuando no hay logo.
This commit is contained in:
2026-06-24 12:16:25 -03:00
parent f47cf82abf
commit dc610f8a1e
4 changed files with 252 additions and 17 deletions

View File

@@ -12,9 +12,46 @@ import {
} from './pdf-sections-roi'
import { drawSharedHeader, applyFootersToAllPages } from './pdf-sections-shared'
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
import logoColorUrl from '../../../assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png?url'
export type ReportTab = 'actual' | 'automatizado' | 'roi'
interface LoadedLogo {
base64: string
pixelWidth: number
pixelHeight: number
}
// Convierte el logo a base64 para incrustarlo en el PDF vía doc.addImage().
// Si el fetch falla (ej. entorno sin red), el caller usa .catch(() => undefined)
// y drawSharedHeader cae al fallback de texto "InQ ROI" — el export nunca se bloquea por esto.
//
// Redimensiona antes de convertir: el asset fuente es 724×474px, pero en el PDF se
// usa a ~15mm de ancho. jsPDF embebe PNG con canal alpha como bitmap RGBA sin
// recomprimir (no reutiliza la compresión del PNG original) — sin este resize, cada
// export engordaba ~1.3MB (de ~60KB a ~1.4MB) solo por el logo. 320px de ancho es
// suficiente para nitidez en impresión a ese tamaño físico.
//
// Retorna las dimensiones en píxeles del bitmap resultante junto al base64: drawSharedHeader
// las necesita para calcular el alto proporcional real (724×474px ≈ ratio 1.53:1, no 3.56:1
// como asumía el hardcode original de 32×9mm — eso aplastaba el logo horizontalmente).
async function loadImageBase64(url: string): Promise<LoadedLogo> {
const response = await fetch(url)
const blob = await response.blob()
const bitmap = await createImageBitmap(blob)
const targetW = 320
const targetH = Math.round(targetW * (bitmap.height / bitmap.width))
const canvas = document.createElement('canvas')
canvas.width = targetW
canvas.height = targetH
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('No se pudo obtener contexto 2D del canvas')
ctx.drawImage(bitmap, 0, 0, targetW, targetH)
return { base64: canvas.toDataURL('image/png'), pixelWidth: targetW, pixelHeight: targetH }
}
export interface PdfExportParams {
process: Process
simulation: Simulation
@@ -35,13 +72,15 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
const logo = await loadImageBase64(logoColorUrl).catch(() => undefined)
if (tab === 'roi') {
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency)
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency, logo)
} else {
const result = tab === 'automatizado'
? (simulation.resultAutomated ?? simulation.result)
: simulation.result
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities)
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities, logo)
}
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
@@ -62,7 +101,8 @@ async function exportScenarioPdf(
heatmapImageData: string | null,
currency: string,
tab: 'actual' | 'automatizado',
activities: Activity[] = []
activities: Activity[] = [],
logo?: LoadedLogo
): Promise<void> {
const docAny = doc as any
const scenarioLabel = tab === 'actual' ? 'Escenario actual' : 'Escenario automatizado'
@@ -75,7 +115,14 @@ async function exportScenarioPdf(
keywords: 'bpmn, costos, simulación',
})
const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
const headerOpts = {
processName: process.name,
clientName: process.clientName || undefined,
simulatedAt: simulation.executedAt,
logoBase64: logo?.base64,
logoPixelWidth: logo?.pixelWidth,
logoPixelHeight: logo?.pixelHeight,
}
// ── Página 1 (portrait): Header denso + metadata + KPI grid (Etapa 8) ────────
drawScenarioPage1(docAny, tab, process, simulation.executedAt, result, currency)
@@ -164,7 +211,8 @@ async function exportRoiPdf(
process: Process,
simulation: Simulation,
activities: Activity[],
currency: string
currency: string,
logo?: LoadedLogo
): Promise<void> {
const result = simulation.result
const resultAutomated = simulation.resultAutomated ?? result
@@ -185,7 +233,14 @@ async function exportRoiPdf(
keywords: 'bpmn, roi, automatización, payback',
})
const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
const roiHeaderOpts = {
processName: process.name,
clientName: process.clientName || undefined,
simulatedAt: simulation.executedAt,
logoBase64: logo?.base64,
logoPixelWidth: logo?.pixelWidth,
logoPixelHeight: logo?.pixelHeight,
}
// ── Página 1: Rediseño completo InQ (Etapa 6) ───────────────────────────────
drawRoiPage1(doc, process, roi, simulation.executedAt, result.perActivity, resultAutomated.perActivity, currency)

View File

@@ -64,11 +64,14 @@ export interface PdfHeaderOptions {
sectionTitle?: string // subtítulo de sección, ej. "Análisis de retorno de inversión"
clientName?: string
simulatedAt: number // Unix timestamp ms
logoBase64?: string // logo PNG de InQuality, opcional — fallback a texto "InQ ROI" si no se provee
logoPixelWidth?: number // dimensiones reales del bitmap — para calcular el alto proporcional sin distorsión
logoPixelHeight?: number
}
/**
* Dibuja el header InQ-branded.
* - Logo "InQ ROI" naranja a la izquierda, fecha a la derecha
* - Logo InQuality (o texto "InQ ROI" naranja como fallback) a la izquierda, fecha a la derecha
* - Nombre del proceso + cliente
* - Border-bottom naranja (más grueso en página 1)
* Retorna la coordenada y donde empieza el contenido.
@@ -77,20 +80,37 @@ export function drawSharedHeader(doc: DocLike, options: PdfHeaderOptions): numbe
const pageW = doc.internal.pageSize.getWidth()
const contentRight = pageW - MARGIN
let y = MARGIN
const hasLogo = Boolean(options.logoBase64)
// Logo "InQ ROI" — naranja bold
doc.setFont('helvetica', 'bold')
doc.setFontSize(13)
doc.setTextColor(...PDF_COLORS.inqOrange)
doc.text('InQ ROI', MARGIN, y)
if (hasLogo) {
// Logo compacto arriba a la izquierda, alineado con el margen. Ancho 15mm con
// alto proporcional calculado desde las dimensiones reales del bitmap — el PNG
// fuente es 724×474px (ratio ~1.53:1), no 3.56:1 como asumía el hardcode anterior
// de 32×9mm (eso aplastaba el logo horizontalmente).
const LOGO_WIDTH_MM = 15
const logoHeightMM = options.logoPixelWidth && options.logoPixelHeight
? LOGO_WIDTH_MM * (options.logoPixelHeight / options.logoPixelWidth)
: 8
doc.addImage(options.logoBase64 as string, 'PNG', MARGIN, MARGIN - 1, LOGO_WIDTH_MM, logoHeightMM)
y = MARGIN + logoHeightMM + 2
} else {
// Fallback al texto — retrocompatibilidad y tests (logo no disponible, ej. fetch falló)
doc.setFont('helvetica', 'bold')
doc.setFontSize(13)
doc.setTextColor(...PDF_COLORS.inqOrange)
doc.text('InQ ROI', MARGIN, y)
}
// Fecha — derecha, slate-400
// Fecha — derecha, slate-400. Sin logo comparte línea con el texto "InQ ROI" (layout
// original). Con logo, el logo ya desplazó el bloque hacia abajo — la fecha cae en
// la misma línea que el nombre del proceso, debajo del logo.
const { date } = formatSimulationTimestamp(options.simulatedAt)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text(date, contentRight, y, { align: 'right' } as Record<string, unknown>)
y += 7
if (!hasLogo) y += 7
// Nombre del proceso — bold slate-900
doc.setFont('helvetica', 'bold')
@@ -98,12 +118,16 @@ export function drawSharedHeader(doc: DocLike, options: PdfHeaderOptions): numbe
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(options.processName, MARGIN, y)
// Cliente — derecha, slate-500
// Cliente — derecha, slate-500. Sin logo comparte línea con el proceso (layout
// original). Con logo, esa línea ya tiene la fecha — el cliente baja una línea para
// no superponerse, y el border-bottom se ajusta para no pisarlo.
if (options.clientName) {
const clientY = hasLogo ? y + 5 : y
doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text(options.clientName, contentRight, y, { align: 'right' } as Record<string, unknown>)
doc.text(options.clientName, contentRight, clientY, { align: 'right' } as Record<string, unknown>)
if (hasLogo) y = clientY
}
y += 5