using System; using System.Collections.Generic; using System.IO; using System.Linq; using HC_APTBS.Models; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; namespace HC_APTBS.Services.Impl { /// /// Generates professional PDF test reports using QuestPDF. /// /// /// Report layout: /// /// Header (every page): company logo, company name, date, operator, client, report title. /// Pump identification table: ID, serial, model, rotation, lock angle. /// K-Line ECU data block: model reference, DFI, SW versions, fault codes. /// Overall verdict section: large PASS/FAIL badge with summary statistics. /// Per-test results: table with pass/fail rows, followed by measurement charts /// showing sample values against target ± tolerance bands. /// Footer (every page): software attribution and page numbers. /// /// /// public sealed class PdfService : IPdfService { private readonly IConfigurationService _config; private readonly byte[]? _defaultLogo; /// Provides company name, logo path, and report settings. public PdfService(IConfigurationService configService) { _config = configService; // QuestPDF community licence — required for open-source use. QuestPDF.Settings.License = LicenseType.Community; // Load the embedded default logo as a fallback. using var stream = typeof(PdfService).Assembly .GetManifestResourceStream("HC_APTBS.Resources.Images.default_logo.png"); if (stream != null) { using var ms = new MemoryStream(); stream.CopyTo(ms); _defaultLogo = ms.ToArray(); } } // ── IPdfService ─────────────────────────────────────────────────────────── /// public string GenerateReport( PumpDefinition pump, string operatorName, string clientName, string outputFolder) { Directory.CreateDirectory(outputFolder); string fileName = SanitiseFileName( $"{pump.Id}_{pump.SerialNumber}_{clientName}_{DateTime.Now:yyyy-MM-dd_HH-mm}.pdf"); string filePath = Path.Combine(outputFolder, fileName); var reportDate = DateTime.Now; var document = Document.Create(container => { container.Page(page => { page.Size(PageSizes.A4); page.Margin(25, Unit.Millimetre); page.DefaultTextStyle(x => x.FontSize(ReportTheme.BodySize).FontFamily(Fonts.Arial)); page.Header().Element(c => ComposeHeader(c, operatorName, clientName, reportDate)); page.Content().Element(c => ComposeContent(c, pump)); page.Footer().Element(ComposeFooter); }); }); document.GeneratePdf(filePath); return filePath; } // ── Header ──────────────────────────────────────────────────────────────── /// Renders the page header: logo, company info, date/operator/client, title. private void ComposeHeader( IContainer container, string operatorName, string clientName, DateTime reportDate) { container.Column(outer => { outer.Item().Row(row => { // Company logo — prefer configured path, fall back to embedded default. if (File.Exists(_config.Settings.ReportLogoPath)) { row.ConstantItem(65).Height(45) .Image(_config.Settings.ReportLogoPath) .FitArea(); } else if (_defaultLogo != null) { row.ConstantItem(65).Height(45) .Image(_defaultLogo) .FitArea(); } // Company name and info. row.RelativeItem().PaddingLeft(10).Column(col => { col.Item().Text(_config.Settings.CompanyName) .Bold().FontSize(ReportTheme.TitleSize); col.Item().Text(_config.Settings.CompanyInfo) .FontSize(ReportTheme.CaptionSize) .FontColor(ReportTheme.HeaderGrey); }); // Date / operator / client block. row.ConstantItem(140).AlignRight().Column(col => { col.Item().Text($"Date: {reportDate:dd/MM/yyyy HH:mm}") .FontSize(ReportTheme.CaptionSize + 1); col.Item().Text($"Operator: {operatorName}") .FontSize(ReportTheme.CaptionSize + 1); col.Item().Text($"Client: {clientName}") .FontSize(ReportTheme.CaptionSize + 1).Bold(); }); }); // Divider line. outer.Item().PaddingTop(4).LineHorizontal(1) .LineColor(ReportTheme.DividerLine); // Report title. outer.Item().PaddingTop(4).PaddingBottom(2) .AlignCenter() .Text("VP44 INJECTION PUMP TEST REPORT") .Bold().FontSize(ReportTheme.SectionHeaderSize) .FontColor(ReportTheme.HeaderNavy); }); } // ── Footer ──────────────────────────────────────────────────────────────── /// Renders the page footer: divider, attribution, and page numbers. private static void ComposeFooter(IContainer container) { container.Column(col => { col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine); col.Item().PaddingTop(3).Row(row => { row.RelativeItem().Text("Generated by HC-APTBS") .FontSize(ReportTheme.FooterSize) .FontColor(ReportTheme.HeaderGrey); row.ConstantItem(100).AlignRight().Text(t => { t.DefaultTextStyle(x => x.FontSize(ReportTheme.FooterSize)); t.Span("Page "); t.CurrentPageNumber(); t.Span(" of "); t.TotalPages(); }); }); }); } // ── Content ─────────────────────────────────────────────────────────────── /// Composes the full report body: pump info, ECU data, verdict, test sections. private static void ComposeContent(IContainer container, PumpDefinition pump) { container.PaddingTop(6).Column(col => { // ── Pump identification ────────────────────────────────────────── col.Item().PaddingBottom(ReportTheme.SectionGap) .Element(c => ComposePumpInfoTable(c, pump)); // ── K-Line ECU data ────────────────────────────────────────────── if (pump.KlineInfo.Count > 0) col.Item().PaddingBottom(ReportTheme.SectionGap) .Element(c => ComposeKlineTable(c, pump)); // ── Overall verdict ────────────────────────────────────────────── col.Item().PaddingBottom(ReportTheme.SectionGap) .Element(c => ComposeVerdictSection(c, pump)); // ── Test results — one section per test ────────────────────────── foreach (var test in pump.Tests) { if (!test.HasResults()) continue; col.Item().PaddingBottom(ReportTheme.SectionGap) .Element(c => ComposeTestSection(c, test)); } }); } // ── Pump info table ─────────────────────────────────────────────────────── /// Renders the pump identification table with alternating row stripes. private static void ComposePumpInfoTable(IContainer container, PumpDefinition pump) { container.Table(table => { table.ColumnsDefinition(cols => { cols.RelativeColumn(1); cols.RelativeColumn(2); cols.RelativeColumn(1); cols.RelativeColumn(2); }); table.Header(header => { header.Cell().ColumnSpan(4) .Background(ReportTheme.HeaderNavy) .Padding(5) .Text("PUMP IDENTIFICATION") .FontColor(Colors.White).Bold() .FontSize(ReportTheme.SectionHeaderSize); }); int rowIndex = 0; void AddRow(string label1, string value1, string label2, string value2) { string bg = (rowIndex++ % 2 == 1) ? ReportTheme.TableAltRow : "#FFFFFF"; table.Cell().Background(bg).Padding(ReportTheme.CellPad) .Text(label1).Bold().FontSize(ReportTheme.BodySize); table.Cell().Background(bg).Padding(ReportTheme.CellPad) .Text(value1).FontSize(ReportTheme.BodySize); table.Cell().Background(bg).Padding(ReportTheme.CellPad) .Text(label2).Bold().FontSize(ReportTheme.BodySize); table.Cell().Background(bg).Padding(ReportTheme.CellPad) .Text(value2).FontSize(ReportTheme.BodySize); } AddRow("Pump ID:", pump.Id, "Model:", pump.Model); AddRow("Serial No.:", pump.SerialNumber, "Injector:", pump.Injector); AddRow("Tube:", pump.Tube, "Valve:", pump.Valve); AddRow("Tension:", pump.Tension, "Rotation:", pump.Rotation); AddRow("Lock Angle:", $"{pump.LockAngle:F2}\u00B0", "Measured:", $"{pump.LockAngleResult:F2}\u00B0"); AddRow("Chaveta:", pump.Chaveta, "Pre-Inj.:", pump.HasPreInjection ? "Yes" : "No"); }); } // ── K-Line table ────────────────────────────────────────────────────────── /// Renders the K-Line ECU data table with alternating row stripes. private static void ComposeKlineTable(IContainer container, PumpDefinition pump) { container.Table(table => { table.ColumnsDefinition(cols => { cols.RelativeColumn(1); cols.RelativeColumn(2); cols.RelativeColumn(1); cols.RelativeColumn(2); }); table.Header(header => { header.Cell().ColumnSpan(4) .Background(ReportTheme.HeaderNavy) .Padding(5) .Text("ECU DATA (K-Line)") .FontColor(Colors.White).Bold() .FontSize(ReportTheme.SectionHeaderSize); }); int rowIndex = 0; void AddKv(string key) { if (!pump.KlineInfo.TryGetValue(key, out var val)) return; string bg = (rowIndex++ % 2 == 1) ? ReportTheme.TableAltRow : "#FFFFFF"; table.Cell().Background(bg).Padding(ReportTheme.CellPad) .Text(key + ":").Bold().FontSize(ReportTheme.BodySize); table.Cell().ColumnSpan(3).Background(bg).Padding(ReportTheme.CellPad) .Text(val).FontSize(ReportTheme.BodySize); } AddKv(KlineKeys.ModelReference); AddKv(KlineKeys.DataRecord); AddKv(KlineKeys.SwVersion1); AddKv(KlineKeys.SwVersion2); AddKv(KlineKeys.PumpControl); AddKv(KlineKeys.Dfi); AddKv(KlineKeys.SerialNumber); AddKv(KlineKeys.Errors); }); } // ── Verdict section ─────────────────────────────────────────────────────── /// Renders the overall test result badge with summary statistics. private static void ComposeVerdictSection(IContainer container, PumpDefinition pump) { // Compute summary statistics. var testsWithResults = pump.Tests.Where(t => t.HasResults()).ToList(); int totalTests = pump.Tests.Count; int testedCount = testsWithResults.Count; int totalPhases = 0; int passedPhases = 0; foreach (var test in testsWithResults) { foreach (var phase in test.Phases) { if (!phase.Enabled || phase.Receives == null) continue; foreach (var tp in phase.Receives) { if (tp.Result == null) continue; totalPhases++; if (tp.Result.Passed) passedPhases++; } } } bool allPassed = totalPhases > 0 && passedPhases == totalPhases; container.Border(1).BorderColor(ReportTheme.DividerLine) .Background(ReportTheme.TableAltRow) .Padding(8) .Row(row => { // PASS/FAIL verdict badge (SVG). row.ConstantItem(100).Height(50) .Svg(ReportChartRenderer.RenderVerdictBadge(100, 50, allPassed)) .FitArea(); // Summary statistics. row.RelativeItem().PaddingLeft(12).Column(col => { col.Item().Text("OVERALL TEST RESULT") .Bold().FontSize(ReportTheme.SectionHeaderSize) .FontColor(ReportTheme.HeaderNavy); col.Item().PaddingTop(4).Text( $"Tests executed: {testedCount} of {totalTests}") .FontSize(ReportTheme.BodySize); col.Item().Text( $"Parameters evaluated: {passedPhases} / {totalPhases} passed") .FontSize(ReportTheme.BodySize); // Per-test mini indicators. col.Item().PaddingTop(4).Row(indicators => { foreach (var test in pump.Tests) { bool hasResults = test.HasResults(); bool testPassed = hasResults && test.Phases .Where(p => p.Enabled && p.Receives != null) .SelectMany(p => p.Receives) .Where(tp => tp.Result != null) .All(tp => tp.Result!.Passed); string indicator = !hasResults ? "--" : testPassed ? "\u2713" : "\u2717"; string color = !hasResults ? ReportTheme.HeaderGrey : testPassed ? ReportTheme.PassGreen : ReportTheme.FailRed; indicators.AutoItem().PaddingRight(8).Text(t => { t.Span($"{test.Name}: ").FontSize(ReportTheme.BodySize); t.Span(indicator).Bold().FontSize(ReportTheme.BodySize) .FontColor(color); }); } }); }); }); } // ── Test results section ────────────────────────────────────────────────── /// Renders a single test: results table followed by measurement charts. private static void ComposeTestSection(IContainer container, TestDefinition test) { container.Column(col => { // Section header bar. col.Item() .Background(ReportTheme.HeaderNavy) .Padding(5) .Text($"TEST: {test.Name}") .FontColor(Colors.White).Bold() .FontSize(ReportTheme.SectionHeaderSize); // Results table. col.Item().Element(c => ComposeResultsTable(c, test)); // Charts — one per parameter that has sample data. col.Item().PaddingTop(ReportTheme.SubsectionGap) .Element(c => ComposeTestCharts(c, test)); }); } /// Renders the pass/fail results table for one test. private static void ComposeResultsTable(IContainer container, TestDefinition test) { container.Table(table => { table.ColumnsDefinition(cols => { cols.RelativeColumn(2); // Phase cols.RelativeColumn(1); // Parameter cols.RelativeColumn(1); // Target cols.RelativeColumn(1); // Tolerance cols.RelativeColumn(1); // Average cols.RelativeColumn(1); // Result }); table.Header(header => { foreach (var h in new[] { "Phase", "Parameter", "Target", "Tolerance \u00B1", "Average", "Result" }) header.Cell() .Background(ReportTheme.AccentBlue) .Padding(ReportTheme.CellPad) .Text(h).FontColor(Colors.White).Bold() .FontSize(ReportTheme.CaptionSize + 1); }); int rowIndex = 0; foreach (var phase in test.Phases) { if (!phase.Enabled || phase.Receives == null) continue; foreach (var tp in phase.Receives) { if (tp.Result == null) continue; bool passed = tp.Result.Passed; string resultText = passed ? "PASS" : "FAIL"; // Alternating base row colour, tinted by pass/fail. string bgColor = passed ? (rowIndex % 2 == 0 ? ReportTheme.PassGreenLight : "#D7ECD9") : (rowIndex % 2 == 0 ? ReportTheme.FailRedLight : "#FFDBDB"); table.Cell().Background(bgColor).Padding(ReportTheme.CellPad) .Text(phase.Name).FontSize(ReportTheme.CaptionSize + 1); table.Cell().Background(bgColor).Padding(ReportTheme.CellPad) .Text(tp.Name).FontSize(ReportTheme.CaptionSize + 1); table.Cell().Background(bgColor).Padding(ReportTheme.CellPad) .Text(tp.Value.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1); table.Cell().Background(bgColor).Padding(ReportTheme.CellPad) .Text(tp.Tolerance.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1); table.Cell().Background(bgColor).Padding(ReportTheme.CellPad) .Text(tp.Result.Average.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1); table.Cell().Background(bgColor).Padding(ReportTheme.CellPad) .Text(resultText).Bold() .FontColor(passed ? ReportTheme.PassGreen : ReportTheme.FailRed) .FontSize(ReportTheme.CaptionSize + 1); rowIndex++; } // Show any alarm bits that fired during this phase. if (phase.ErrorBits?.Count > 0) { table.Cell().ColumnSpan(6) .Background(ReportTheme.WarningBg) .Padding(ReportTheme.CellPad) .Text($" \u26A0 Error bits: {string.Join(", ", phase.ErrorBits)}") .FontSize(ReportTheme.CaptionSize + 1) .FontColor(ReportTheme.WarningText); } } }); } /// Renders measurement charts for each parameter that has sample data. private static void ComposeTestCharts(IContainer container, TestDefinition test) { container.Column(col => { bool anyChart = false; foreach (var phase in test.Phases) { if (!phase.Enabled || phase.Receives == null) continue; foreach (var tp in phase.Receives) { if (tp.Result == null || tp.Result.Samples.Count < 2) continue; anyChart = true; string label = $"{phase.Name} \u2014 {tp.Name}"; // The chart itself (SVG rendered inline). string chartSvg = ReportChartRenderer.RenderMeasurementChart( 480, ReportTheme.ChartHeight, tp.Result.Samples, tp.Value, tp.Tolerance, tp.Result.Average, tp.Result.Passed, label); col.Item().PaddingBottom(2) .Svg(chartSvg) .FitWidth(); // Chart caption. col.Item().PaddingBottom(ReportTheme.SubsectionGap) .Text($"Samples: {tp.Result.Samples.Count} | " + $"Target: {tp.Value:F2} \u00B1 {tp.Tolerance:F2} | " + $"Average: {tp.Result.Average:F2} | " + $"Result: {(tp.Result.Passed ? "PASS" : "FAIL")}") .FontSize(ReportTheme.CaptionSize) .FontColor(ReportTheme.HeaderGrey); } } if (!anyChart) { col.Item().PaddingTop(2).PaddingBottom(4) .Text("No sample data available for graphical display.") .FontSize(ReportTheme.CaptionSize).Italic() .FontColor(ReportTheme.HeaderGrey); } }); } // ── Helpers ─────────────────────────────────────────────────────────────── /// Sanitises a file name by replacing invalid characters with underscores. private static string SanitiseFileName(string name) { foreach (char c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_'); return name; } } }