/** * PDF Export utilities for Iapetus reports. * Uses jsPDF for document generation and html2canvas for chart capture. */ import { jsPDF } from "jspdf"; import html2canvas from "html2canvas"; import { formatCategory, formatPackaging, formatOxygen, formatPreservative, formatAcidulant, formatRiskClass, formatConfidence, formatFeature, formatDirection, } from "./formatters"; const APP_VERSION = "0.2.0"; // Color definitions matching the app theme const COLORS = { accent: [38, 70, 83], // #264653 accentLight: [51, 101, 138], // #33658a riskLow: [42, 157, 143], // #2a9d8f riskModerate: [233, 196, 106], // #e9c46a riskHigh: [231, 111, 81], // #e76f51 riskCritical: [143, 29, 20], // #8f1d14 text: [27, 42, 47], // #1b2a2f muted: [88, 101, 107], // #58656b border: [216, 221, 212], // #d8ddd4 }; function getRiskColor(riskClass) { const normalized = String(riskClass).toLowerCase(); if (normalized === "low") return COLORS.riskLow; if (normalized === "moderate") return COLORS.riskModerate; if (normalized === "high") return COLORS.riskHigh; if (normalized === "critical") return COLORS.riskCritical; return COLORS.muted; } function generateFilename() { const now = new Date(); const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19); return `iapetus_report_${timestamp}.pdf`; } function formatTimestamp() { return new Date().toLocaleString("en-GB", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", }); } /** * Generate a PDF report from simulation results. * @param {object} result - The simulation result from the API * @param {object} scenario - The input scenario parameters * @param {HTMLElement} chartElement - The chart DOM element to capture */ export async function exportReportAsPDF(result, scenario, chartElement) { const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4", }); const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); const margin = 15; const contentWidth = pageWidth - (margin * 2); let y = margin; // Helper function for adding text function addText(text, x, currentY, options = {}) { const { fontSize = 10, fontStyle = "normal", color = COLORS.text, maxWidth = contentWidth, } = options; doc.setFontSize(fontSize); doc.setFont("helvetica", fontStyle); doc.setTextColor(...color); const lines = doc.splitTextToSize(text, maxWidth); doc.text(lines, x, currentY); return currentY + (lines.length * fontSize * 0.4); } // Helper for section headers function addSectionHeader(title, currentY) { doc.setFillColor(...COLORS.accent); doc.rect(margin, currentY - 4, contentWidth, 8, "F"); doc.setFontSize(11); doc.setFont("helvetica", "bold"); doc.setTextColor(255, 255, 255); doc.text(title, margin + 3, currentY + 1); return currentY + 10; } // Helper for drawing a metric box function addMetricBox(label, value, x, currentY, width, color = COLORS.accent) { doc.setDrawColor(...COLORS.border); doc.setLineWidth(0.3); doc.rect(x, currentY, width, 18); doc.setFontSize(8); doc.setFont("helvetica", "normal"); doc.setTextColor(...COLORS.muted); doc.text(label, x + 3, currentY + 5); doc.setFontSize(12); doc.setFont("helvetica", "bold"); doc.setTextColor(...color); doc.text(String(value), x + 3, currentY + 13); } // ===================== // HEADER // ===================== doc.setFillColor(...COLORS.accent); doc.rect(0, 0, pageWidth, 28, "F"); doc.setFontSize(18); doc.setFont("helvetica", "bold"); doc.setTextColor(255, 255, 255); doc.text("IAPETUS", margin, 12); doc.setFontSize(11); doc.setFont("helvetica", "normal"); doc.text("Shelf-Life Risk Assessment Report", margin, 19); doc.setFontSize(8); doc.text(`Generated: ${formatTimestamp()} | v${APP_VERSION}`, margin, 25); y = 36; // ===================== // EXECUTIVE SUMMARY // ===================== y = addSectionHeader("EXECUTIVE SUMMARY", y); const decision = result.decision || {}; const riskClass = decision.growth_risk_class || "unknown"; const riskColor = getRiskColor(riskClass); // Metric boxes const boxWidth = (contentWidth - 6) / 3; addMetricBox("Risk Level", formatRiskClass(riskClass), margin, y, boxWidth, riskColor); addMetricBox( "Threshold Probability", `${((decision.threshold_exceedance_probability || 0) * 100).toFixed(1)}%`, margin + boxWidth + 3, y, boxWidth ); addMetricBox( "Recommended Shelf Life", `${decision.recommended_max_shelf_life_days || "N/A"} days`, margin + (boxWidth * 2) + 6, y, boxWidth ); y += 24; // Additional metrics row doc.setFontSize(9); doc.setFont("helvetica", "normal"); doc.setTextColor(...COLORS.text); const challengeText = decision.challenge_test_recommended ? "Yes" : "No"; const confidenceText = formatConfidence(decision.confidence_label || "unknown"); doc.text(`Challenge Test Recommended: ${challengeText} | Confidence: ${confidenceText}`, margin, y); y += 8; // ===================== // SCENARIO PARAMETERS // ===================== y = addSectionHeader("SCENARIO PARAMETERS", y); const params = [ ["Product Category", formatCategory(scenario.product_category)], ["Pathogen", scenario.pathogen || "Listeria monocytogenes"], ["Storage Temperature", `${scenario.storage_temperature_c}°C`], ["Target Shelf Life", `${scenario.target_shelf_life_days} days`], ["pH", scenario.ph], ["Water Activity (aw)", scenario.aw], ["Salt", `${scenario.salt_percent}%`], ["Sugar", `${scenario.sugar_percent}%`], ["Fat", `${scenario.fat_percent}%`], ["Preservative", scenario.preservative_flag ? formatPreservative(scenario.preservative_type) : "None"], ["Acidulant", formatAcidulant(scenario.acidulant_type)], ["Packaging", formatPackaging(scenario.packaging_type)], ["Oxygen Condition", formatOxygen(scenario.oxygen_condition)], ["Initial Inoculum", `${scenario.initial_inoculum_log_cfu_g} log CFU/g`], ]; // Two-column layout for parameters const colWidth = contentWidth / 2; const rowHeight = 5; doc.setFontSize(8); params.forEach((param, index) => { const col = index % 2; const row = Math.floor(index / 2); const x = margin + (col * colWidth); const rowY = y + (row * rowHeight); doc.setFont("helvetica", "normal"); doc.setTextColor(...COLORS.muted); doc.text(`${param[0]}:`, x, rowY); doc.setFont("helvetica", "bold"); doc.setTextColor(...COLORS.text); doc.text(String(param[1]), x + 40, rowY); }); y += Math.ceil(params.length / 2) * rowHeight + 6; // ===================== // GROWTH CHART // ===================== if (chartElement) { y = addSectionHeader("GROWTH PREDICTION", y); try { const canvas = await html2canvas(chartElement, { scale: 2, backgroundColor: "#ffffff", logging: false, }); const imgData = canvas.toDataURL("image/png"); const imgWidth = contentWidth; const imgHeight = (canvas.height / canvas.width) * imgWidth; // Check if we need a new page if (y + imgHeight > pageHeight - margin) { doc.addPage(); y = margin; y = addSectionHeader("GROWTH PREDICTION (continued)", y); } doc.addImage(imgData, "PNG", margin, y, imgWidth, imgHeight); y += imgHeight + 6; } catch (error) { console.error("Failed to capture chart:", error); y = addText("Chart capture failed. See web application for visualization.", margin, y); y += 6; } } // Check page space for risk drivers if (y > pageHeight - 80) { doc.addPage(); y = margin; } // ===================== // KEY RISK FACTORS // ===================== const drivers = result.sensitivity_analysis?.drivers || []; if (drivers.length > 0) { y = addSectionHeader("KEY RISK FACTORS", y); // Table header doc.setFillColor(245, 245, 245); doc.rect(margin, y, contentWidth, 6, "F"); doc.setFontSize(8); doc.setFont("helvetica", "bold"); doc.setTextColor(...COLORS.text); doc.text("Factor", margin + 2, y + 4); doc.text("Probability Impact", margin + 55, y + 4); doc.text("Growth Change", margin + 95, y + 4); doc.text("Shelf-Life Impact", margin + 135, y + 4); y += 8; // Table rows (top 6 drivers) const topDrivers = drivers.slice(0, 6); doc.setFontSize(8); doc.setFont("helvetica", "normal"); topDrivers.forEach((driver) => { const probImpact = (driver.impact_on_exceedance_probability * 100); const logImpact = driver.impact_on_final_log_cfu_g; const shelfImpact = driver.impact_on_recommended_shelf_life_days; // Factor name with direction doc.setTextColor(...COLORS.text); doc.text(formatFeature(driver.feature), margin + 2, y); // Probability impact (color-coded) const probColor = probImpact >= 0 ? COLORS.riskHigh : COLORS.riskLow; doc.setTextColor(...probColor); doc.text(`${probImpact >= 0 ? "+" : ""}${probImpact.toFixed(1)}%`, margin + 55, y); // Growth change (color-coded) const logColor = logImpact >= 0 ? COLORS.riskHigh : COLORS.riskLow; doc.setTextColor(...logColor); doc.text(`${logImpact >= 0 ? "+" : ""}${logImpact.toFixed(2)} log CFU/g`, margin + 95, y); // Shelf-life impact (color-coded) const shelfColor = shelfImpact <= 0 ? COLORS.riskHigh : COLORS.riskLow; doc.setTextColor(...shelfColor); doc.text(`${shelfImpact > 0 ? "+" : ""}${shelfImpact} days`, margin + 135, y); y += 5; }); y += 4; } // Check page space for summary if (y > pageHeight - 60) { doc.addPage(); y = margin; } // ===================== // AI ANALYSIS // ===================== if (result.summary) { y = addSectionHeader("AI ANALYSIS", y); doc.setFontSize(9); doc.setFont("helvetica", "normal"); doc.setTextColor(...COLORS.text); const summaryLines = doc.splitTextToSize(result.summary, contentWidth - 4); doc.text(summaryLines, margin + 2, y); y += summaryLines.length * 4; if (result.summary_provider) { doc.setFontSize(7); doc.setTextColor(...COLORS.muted); doc.text(`Source: ${result.summary_provider}`, margin + 2, y + 2); y += 6; } } // ===================== // DISCLAIMER // ===================== // Ensure disclaimer fits on page if (y > pageHeight - 30) { doc.addPage(); y = margin; } y += 4; doc.setDrawColor(...COLORS.border); doc.setLineWidth(0.5); doc.line(margin, y, margin + contentWidth, y); y += 6; doc.setFillColor(255, 250, 240); doc.rect(margin, y, contentWidth, 20, "F"); doc.setDrawColor(...COLORS.riskModerate); doc.setLineWidth(0.3); doc.rect(margin, y, contentWidth, 20, "S"); doc.setFontSize(7); doc.setFont("helvetica", "bold"); doc.setTextColor(...COLORS.riskHigh); doc.text("IMPORTANT DISCLAIMER", margin + 3, y + 4); doc.setFont("helvetica", "normal"); doc.setTextColor(...COLORS.text); const disclaimer = "This report is generated by an exploratory simulation tool trained on synthetic data. It is not a regulatory-validated decision-making tool and should not be used as a substitute for formal challenge testing or shelf-life validation. Always consult with qualified food safety professionals."; const disclaimerLines = doc.splitTextToSize(disclaimer, contentWidth - 6); doc.text(disclaimerLines, margin + 3, y + 9); // Save the PDF doc.save(generateFilename()); } /** * Export the simulation report as PDF (main entry point). * @param {object} result - The simulation result from the API * @param {object} scenario - The input scenario parameters * @param {HTMLElement} chartElement - The chart DOM element to capture */ export async function exportReport(result, scenario, chartElement) { await exportReportAsPDF(result, scenario, chartElement); }