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 ILocalizationService _loc; private readonly byte[]? _defaultLogo; /// Provides company name, logo path, and report settings. /// Provides localised strings for report text. public PdfService(IConfigurationService configService, ILocalizationService localizationService) { _config = configService; _loc = localizationService; // 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, string? clientInfo = null, string? observations = null) { 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, clientInfo, reportDate)); page.Content().Element(c => ComposeContent(c, pump, observations)); 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, string? clientInfo, 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(string.Format(_loc.GetString("Pdf.Date"), reportDate)) .FontSize(ReportTheme.CaptionSize + 1); col.Item().Text(string.Format(_loc.GetString("Pdf.Operator"), operatorName)) .FontSize(ReportTheme.CaptionSize + 1); col.Item().Text(string.Format(_loc.GetString("Pdf.Client"), clientName)) .FontSize(ReportTheme.CaptionSize + 1).Bold(); // Optional multi-line client address/contact info. if (!string.IsNullOrWhiteSpace(clientInfo)) col.Item().Text(clientInfo) .FontSize(ReportTheme.CaptionSize) .FontColor(ReportTheme.HeaderGrey); }); }); // Divider line. outer.Item().PaddingTop(4).LineHorizontal(1) .LineColor(ReportTheme.DividerLine); // Report title. outer.Item().PaddingTop(4).PaddingBottom(2) .AlignCenter() .Text(_loc.GetString("Pdf.ReportTitle")) .Bold().FontSize(ReportTheme.SectionHeaderSize) .FontColor(ReportTheme.HeaderNavy); }); } // ── Footer ──────────────────────────────────────────────────────────────── /// Renders the page footer: divider, attribution, and page numbers. private void ComposeFooter(IContainer container) { container.Column(col => { col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine); col.Item().PaddingTop(3).Row(row => { row.RelativeItem().Text(_loc.GetString("Pdf.GeneratedBy")) .FontSize(ReportTheme.FooterSize) .FontColor(ReportTheme.HeaderGrey); row.ConstantItem(100).AlignRight().Text(t => { t.DefaultTextStyle(x => x.FontSize(ReportTheme.FooterSize)); t.Span(_loc.GetString("Pdf.Page")); t.CurrentPageNumber(); t.Span(_loc.GetString("Pdf.Of")); t.TotalPages(); }); }); }); } // ── Content ─────────────────────────────────────────────────────────────── /// Composes the full report body: pump info, ECU data, verdict, test sections, observations. private void ComposeContent(IContainer container, PumpDefinition pump, string? observations) { 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)); } // ── Operator observations (free-text) ──────────────────────────── if (!string.IsNullOrWhiteSpace(observations)) col.Item().PaddingBottom(ReportTheme.SectionGap) .Element(c => ComposeObservationsSection(c, observations!)); }); } // ── Pump info table ─────────────────────────────────────────────────────── /// Renders the pump identification table with alternating row stripes. private 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(_loc.GetString("Pdf.PumpIdentification")) .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(_loc.GetString("Pdf.PumpId"), pump.Id, _loc.GetString("Pdf.Model"), pump.Model); AddRow(_loc.GetString("Pdf.SerialNo"), pump.SerialNumber, _loc.GetString("Pdf.Injector"), pump.Injector); AddRow(_loc.GetString("Pdf.Tube"), pump.Tube, _loc.GetString("Pdf.Valve"), pump.Valve); AddRow(_loc.GetString("Pdf.Tension"), pump.Tension, _loc.GetString("Pdf.Rotation"), pump.Rotation); AddRow(_loc.GetString("Pdf.LockAngle"), $"{pump.LockAngle:F2}\u00B0", _loc.GetString("Pdf.Measured"), $"{pump.LockAngleResult:F2}\u00B0"); AddRow(_loc.GetString("Pdf.Chaveta"), pump.Chaveta, _loc.GetString("Pdf.PreInj"), pump.HasPreInjection ? _loc.GetString("Common.Yes") : _loc.GetString("Common.No")); }); } // ── K-Line table ────────────────────────────────────────────────────────── /// Renders the K-Line ECU data table with alternating row stripes. private 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(_loc.GetString("Pdf.EcuData")) .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 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(_loc.GetString("Pdf.OverallResult")) .Bold().FontSize(ReportTheme.SectionHeaderSize) .FontColor(ReportTheme.HeaderNavy); col.Item().PaddingTop(4).Text( string.Format(_loc.GetString("Pdf.TestsExecuted"), testedCount, totalTests)) .FontSize(ReportTheme.BodySize); col.Item().Text( string.Format(_loc.GetString("Pdf.ParamsEvaluated"), passedPhases, totalPhases)) .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); }); } }); }); }); } // ── Observations section ────────────────────────────────────────────────── /// Renders a free-text "Observations" block at the bottom of the report. private void ComposeObservationsSection(IContainer container, string observations) { container.Column(col => { // Section header bar — matches the navy style used by test sections. col.Item() .Background(ReportTheme.HeaderNavy) .Padding(5) .Text(_loc.GetString("Pdf.Observations")) .FontColor(Colors.White) .Bold().FontSize(ReportTheme.SectionHeaderSize); // Bordered text block — matches the verdict block visual treatment. col.Item() .Border(1).BorderColor(ReportTheme.DividerLine) .Background(ReportTheme.TableAltRow) .Padding(8) .Text(observations) .FontSize(ReportTheme.BodySize); }); } // ── Test results section ────────────────────────────────────────────────── /// Renders a single test: results table followed by measurement charts. private void ComposeTestSection(IContainer container, TestDefinition test) { container.Column(col => { // Section header bar. col.Item() .Background(ReportTheme.HeaderNavy) .Padding(5) .Text(string.Format(_loc.GetString("Pdf.TestHeader"), 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 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[] { _loc.GetString("Pdf.Phase"), _loc.GetString("Pdf.Parameter"), _loc.GetString("Pdf.Target"), _loc.GetString("Pdf.ToleranceHeader"), _loc.GetString("Pdf.Average"), _loc.GetString("Pdf.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 ? _loc.GetString("Common.Pass") : _loc.GetString("Common.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(string.Format(_loc.GetString("Pdf.ErrorBits"), string.Join(", ", phase.ErrorBits))) .FontSize(ReportTheme.CaptionSize + 1) .FontColor(ReportTheme.WarningText); } } }); } /// Renders measurement charts for each parameter that has sample data. private 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. string passFailText = tp.Result.Passed ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail"); col.Item().PaddingBottom(ReportTheme.SubsectionGap) .Text(string.Format(_loc.GetString("Pdf.ChartSamples"), tp.Result.Samples.Count, tp.Value, tp.Tolerance, tp.Result.Average, passFailText)) .FontSize(ReportTheme.CaptionSize) .FontColor(ReportTheme.HeaderGrey); } } if (!anyChart) { col.Item().PaddingTop(2).PaddingBottom(4) .Text(_loc.GetString("Pdf.NoSampleData")) .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; } } }