feat: implement SavePump/SaveAlarms, fix config round-trip bugs, redesign PDF reports
Config system fixes: - Implement SavePump() — full XML serialization with insert/update by pump ID - Add CanBusParameter.ToPumpXml() for legacy P1-P6 pump param format - Fix LastRotationDirection never loaded in LoadSettings() - Add SaveAlarms() to ConfigurationService and IConfigurationService - Remove dead fields AppSettings.Clients and AppSettings.PumpIds PDF report redesign: - Professional layout with charts, verdict badges, and tolerance bands - Add ReportChartRenderer (SVG) and ReportTheme styling constants - Embed default_logo.png as fallback report logo Documentation: - Add gap analysis docs (config validation, ford unlock, missing features) - Update CLAUDE.md architecture, known gaps, and debt tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -190,7 +190,84 @@ namespace HC_APTBS.Services.Impl
|
||||
/// <inheritdoc/>
|
||||
public void SavePump(PumpDefinition pump)
|
||||
{
|
||||
_log.Info(LogId, $"SavePump({pump.Id}) — not yet implemented.");
|
||||
try
|
||||
{
|
||||
XDocument xdoc;
|
||||
if (File.Exists(PumpsXml))
|
||||
{
|
||||
xdoc = XDocument.Load(PumpsXml);
|
||||
}
|
||||
else
|
||||
{
|
||||
xdoc = new XDocument(new XElement("Config", new XElement("Pumps")));
|
||||
}
|
||||
|
||||
var root = xdoc.Root!;
|
||||
var pumpsNode = root.Element("Pumps");
|
||||
if (pumpsNode == null)
|
||||
{
|
||||
pumpsNode = new XElement("Pumps");
|
||||
root.Add(pumpsNode);
|
||||
}
|
||||
|
||||
// Build the <Pump> element mirroring ParsePumpElement's expected format.
|
||||
var xpump = new XElement("Pump",
|
||||
new XAttribute("id", pump.Id),
|
||||
new XAttribute("model", pump.Model),
|
||||
new XAttribute("text", pump.EcuText),
|
||||
new XAttribute("chaveta", pump.Chaveta),
|
||||
new XAttribute("rotation", pump.Rotation),
|
||||
new XAttribute("info", pump.Info),
|
||||
new XAttribute("preinjection", pump.HasPreInjection.ToString().ToLowerInvariant()),
|
||||
new XAttribute("cilinders4", pump.Is4Cylinder.ToString().ToLowerInvariant()),
|
||||
new XAttribute("unlock", pump.UnlockType),
|
||||
new XAttribute("baudrate", pump.CanBaudrate == Peak.Can.Basic.TPCANBaudrate.PCAN_BAUD_250K ? "250" : "500"),
|
||||
new XAttribute("lockangle", pump.LockAngle.ToString(CultureInfo.InvariantCulture)));
|
||||
|
||||
// PumpID child element — GetPumpIds() finds these via Descendants("PumpID").
|
||||
xpump.Add(new XElement("PumpID", pump.Id));
|
||||
|
||||
// ── Serialise <Params> (pump CAN params use legacy P1–P6 format) ──
|
||||
if (pump.ParametersByName.Count > 0)
|
||||
{
|
||||
var xparams = new XElement("Params");
|
||||
foreach (var param in pump.ParametersByName.Values)
|
||||
xparams.Add(param.ToPumpXml());
|
||||
xpump.Add(xparams);
|
||||
}
|
||||
|
||||
// ── Serialise <Tests> ──
|
||||
if (pump.Tests.Count > 0)
|
||||
{
|
||||
var xtests = new XElement("Tests");
|
||||
foreach (var test in pump.Tests)
|
||||
xtests.Add(test.ToXml());
|
||||
xpump.Add(xtests);
|
||||
}
|
||||
|
||||
// ── Find existing pump by ID and replace, or append ──
|
||||
XElement? existing = null;
|
||||
foreach (var child in pumpsNode.Elements("Pump"))
|
||||
{
|
||||
if (child.Attribute("id")?.Value == pump.Id)
|
||||
{
|
||||
existing = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing != null)
|
||||
existing.ReplaceWith(xpump);
|
||||
else
|
||||
pumpsNode.Add(xpump);
|
||||
|
||||
xdoc.Save(PumpsXml);
|
||||
_log.Info(LogId, $"SavePump({pump.Id}) — saved to pumps.xml.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(LogId, $"SavePump({pump.Id}) failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── IConfigurationService: Clients ────────────────────────────────────────
|
||||
@@ -241,6 +318,25 @@ namespace HC_APTBS.Services.Impl
|
||||
}
|
||||
}
|
||||
|
||||
// ── IConfigurationService: Alarms ─────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SaveAlarms()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = new XElement("Alarms");
|
||||
foreach (var alarm in Settings.Alarms)
|
||||
root.Add(alarm.ToXml());
|
||||
new XDocument(root).Save(AlarmsXml);
|
||||
_log.Info(LogId, "Alarms saved.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(LogId, $"SaveAlarms failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pump status definitions ───────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -349,6 +445,7 @@ namespace HC_APTBS.Services.Impl
|
||||
TryInt(r, "MaxRpm", v => _settings.MaxRpm = v);
|
||||
TryBool(r, "RightRelayValue", v => _settings.RightRelayValue = v);
|
||||
TryBool(r, "DefaultIgnoreTin", v => _settings.DefaultIgnoreTin = v);
|
||||
TryInt(r, "LastRotationDir", v => _settings.LastRotationDirection = (short)v);
|
||||
TryInt(r, "DaysKeepLogs", v => _settings.DaysKeepLogs = v);
|
||||
TryString(r, "CompanyName", v => _settings.CompanyName = v);
|
||||
TryString(r, "CompanyInfo", v => _settings.CompanyInfo = v);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using HC_APTBS.Models;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
@@ -8,23 +10,25 @@ using QuestPDF.Infrastructure;
|
||||
namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates PDF test reports using QuestPDF.
|
||||
/// Generates professional PDF test reports using QuestPDF.
|
||||
///
|
||||
/// <para>
|
||||
/// Report layout:
|
||||
/// <list type="bullet">
|
||||
/// <item>Header: company logo, company name, date, operator, client.</item>
|
||||
/// <item>Header (every page): company logo, company name, date, operator, client, report title.</item>
|
||||
/// <item>Pump identification table: ID, serial, model, rotation, lock angle.</item>
|
||||
/// <item>K-Line ECU data block: model reference, DFI, SW versions, fault codes.</item>
|
||||
/// <item>Per-test results section: one table per enabled phase showing measured
|
||||
/// average vs. target ± tolerance and a pass/fail indicator.</item>
|
||||
/// <item>Footer: page numbers.</item>
|
||||
/// <item>Overall verdict section: large PASS/FAIL badge with summary statistics.</item>
|
||||
/// <item>Per-test results: table with pass/fail rows, followed by measurement charts
|
||||
/// showing sample values against target ± tolerance bands.</item>
|
||||
/// <item>Footer (every page): software attribution and page numbers.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PdfService : IPdfService
|
||||
{
|
||||
private readonly IConfigurationService _config;
|
||||
private readonly byte[]? _defaultLogo;
|
||||
|
||||
/// <param name="configService">Provides company name, logo path, and report settings.</param>
|
||||
public PdfService(IConfigurationService configService)
|
||||
@@ -33,6 +37,16 @@ namespace HC_APTBS.Services.Impl
|
||||
|
||||
// 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 ───────────────────────────────────────────────────────────
|
||||
@@ -50,23 +64,19 @@ namespace HC_APTBS.Services.Impl
|
||||
$"{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(9).FontFamily(Fonts.Arial));
|
||||
page.DefaultTextStyle(x => x.FontSize(ReportTheme.BodySize).FontFamily(Fonts.Arial));
|
||||
|
||||
page.Header().Element(c => ComposeHeader(c, pump, operatorName, clientName));
|
||||
page.Header().Element(c => ComposeHeader(c, operatorName, clientName, reportDate));
|
||||
page.Content().Element(c => ComposeContent(c, pump));
|
||||
page.Footer().AlignCenter().Text(t =>
|
||||
{
|
||||
t.Span("Page ");
|
||||
t.CurrentPageNumber();
|
||||
t.Span(" of ");
|
||||
t.TotalPages();
|
||||
});
|
||||
page.Footer().Element(ComposeFooter);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,60 +86,122 @@ namespace HC_APTBS.Services.Impl
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the page header: logo, company info, date/operator/client, title.</summary>
|
||||
private void ComposeHeader(
|
||||
IContainer container, PumpDefinition pump, string operatorName, string clientName)
|
||||
IContainer container, string operatorName, string clientName, DateTime reportDate)
|
||||
{
|
||||
container.Row(row =>
|
||||
container.Column(outer =>
|
||||
{
|
||||
// Company logo (optional)
|
||||
if (File.Exists(_config.Settings.ReportLogoPath))
|
||||
outer.Item().Row(row =>
|
||||
{
|
||||
row.ConstantItem(60).Height(40)
|
||||
.Image(_config.Settings.ReportLogoPath)
|
||||
.FitArea();
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
|
||||
row.RelativeItem().PaddingLeft(10).Column(col =>
|
||||
{
|
||||
col.Item().Text(_config.Settings.CompanyName)
|
||||
.Bold().FontSize(14);
|
||||
col.Item().Text(_config.Settings.CompanyInfo)
|
||||
.FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
row.ConstantItem(130).Column(col =>
|
||||
// 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the page footer: divider, attribution, and page numbers.</summary>
|
||||
private static void ComposeFooter(IContainer container)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine);
|
||||
col.Item().PaddingTop(3).Row(row =>
|
||||
{
|
||||
col.Item().Text($"Date: {DateTime.Now:dd/MM/yyyy HH:mm}").FontSize(8);
|
||||
col.Item().Text($"Operator: {operatorName}").FontSize(8);
|
||||
col.Item().Text($"Client: {clientName}").FontSize(8);
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Composes the full report body: pump info, ECU data, verdict, test sections.</summary>
|
||||
private static void ComposeContent(IContainer container, PumpDefinition pump)
|
||||
{
|
||||
container.PaddingTop(10).Column(col =>
|
||||
container.PaddingTop(6).Column(col =>
|
||||
{
|
||||
// ── Pump identification ──────────────────────────────────────────
|
||||
col.Item().PaddingBottom(8).Element(c => ComposePumpInfoTable(c, pump));
|
||||
col.Item().PaddingBottom(ReportTheme.SectionGap)
|
||||
.Element(c => ComposePumpInfoTable(c, pump));
|
||||
|
||||
// ── K-Line ECU data ──────────────────────────────────────────────
|
||||
if (pump.KlineInfo.Count > 0)
|
||||
col.Item().PaddingBottom(8).Element(c => ComposeKlineTable(c, pump));
|
||||
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(6).Element(c => ComposeTestSection(c, test));
|
||||
col.Item().PaddingBottom(ReportTheme.SectionGap)
|
||||
.Element(c => ComposeTestSection(c, test));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pump info table ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the pump identification table with alternating row stripes.</summary>
|
||||
private static void ComposePumpInfoTable(IContainer container, PumpDefinition pump)
|
||||
{
|
||||
container.Table(table =>
|
||||
@@ -145,32 +217,41 @@ namespace HC_APTBS.Services.Impl
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().ColumnSpan(4)
|
||||
.Background(Colors.Blue.Darken3)
|
||||
.Padding(4)
|
||||
.Background(ReportTheme.HeaderNavy)
|
||||
.Padding(5)
|
||||
.Text("PUMP IDENTIFICATION")
|
||||
.FontColor(Colors.White).Bold().FontSize(10);
|
||||
.FontColor(Colors.White).Bold()
|
||||
.FontSize(ReportTheme.SectionHeaderSize);
|
||||
});
|
||||
|
||||
int rowIndex = 0;
|
||||
|
||||
void AddRow(string label1, string value1, string label2, string value2)
|
||||
{
|
||||
table.Cell().Padding(3).Text(label1).Bold();
|
||||
table.Cell().Padding(3).Text(value1);
|
||||
table.Cell().Padding(3).Text(label2).Bold();
|
||||
table.Cell().Padding(3).Text(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}°",
|
||||
"Measured:", $"{pump.LockAngleResult:F2}°");
|
||||
AddRow("Chaveta:", pump.Chaveta, "Pre-Inj.:", pump.HasPreInjection ? "Yes" : "No");
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the K-Line ECU data table with alternating row stripes.</summary>
|
||||
private static void ComposeKlineTable(IContainer container, PumpDefinition pump)
|
||||
{
|
||||
container.Table(table =>
|
||||
@@ -186,19 +267,24 @@ namespace HC_APTBS.Services.Impl
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().ColumnSpan(4)
|
||||
.Background(Colors.Blue.Darken2)
|
||||
.Padding(4)
|
||||
.Background(ReportTheme.HeaderNavy)
|
||||
.Padding(5)
|
||||
.Text("ECU DATA (K-Line)")
|
||||
.FontColor(Colors.White).Bold().FontSize(10);
|
||||
.FontColor(Colors.White).Bold()
|
||||
.FontSize(ReportTheme.SectionHeaderSize);
|
||||
});
|
||||
|
||||
int rowIndex = 0;
|
||||
|
||||
void AddKv(string key)
|
||||
{
|
||||
if (pump.KlineInfo.TryGetValue(key, out var val))
|
||||
{
|
||||
table.Cell().Padding(3).Text(key + ":").Bold();
|
||||
table.Cell().ColumnSpan(3).Padding(3).Text(val);
|
||||
}
|
||||
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);
|
||||
@@ -212,81 +298,243 @@ namespace HC_APTBS.Services.Impl
|
||||
});
|
||||
}
|
||||
|
||||
// ── Verdict section ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the overall test result badge with summary statistics.</summary>
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders a single test: results table followed by measurement charts.</summary>
|
||||
private static void ComposeTestSection(IContainer container, TestDefinition test)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
// Section header bar.
|
||||
col.Item()
|
||||
.Background(Colors.Grey.Lighten2)
|
||||
.Padding(4)
|
||||
.Background(ReportTheme.HeaderNavy)
|
||||
.Padding(5)
|
||||
.Text($"TEST: {test.Name}")
|
||||
.Bold().FontSize(10);
|
||||
.FontColor(Colors.White).Bold()
|
||||
.FontSize(ReportTheme.SectionHeaderSize);
|
||||
|
||||
col.Item().Table(table =>
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Renders the pass/fail results table for one test.</summary>
|
||||
private static void ComposeResultsTable(IContainer container, TestDefinition test)
|
||||
{
|
||||
container.Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
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 ±", "Average", "Result" })
|
||||
header.Cell()
|
||||
.Background(Colors.Grey.Darken1)
|
||||
.Padding(3)
|
||||
.Text(h).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
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";
|
||||
string bgColor = passed ? Colors.Green.Lighten4 : Colors.Red.Lighten4;
|
||||
|
||||
table.Cell().Background(bgColor).Padding(3).Text(phase.Name).FontSize(8);
|
||||
table.Cell().Background(bgColor).Padding(3).Text(tp.Name).FontSize(8);
|
||||
table.Cell().Background(bgColor).Padding(3)
|
||||
.Text(tp.Value.ToString("F2")).FontSize(8);
|
||||
table.Cell().Background(bgColor).Padding(3)
|
||||
.Text(tp.Tolerance.ToString("F2")).FontSize(8);
|
||||
table.Cell().Background(bgColor).Padding(3)
|
||||
.Text(tp.Result.Average.ToString("F2")).FontSize(8);
|
||||
table.Cell().Background(bgColor).Padding(3)
|
||||
.Text(resultText).Bold()
|
||||
.FontColor(passed ? Colors.Green.Darken2 : Colors.Red.Darken2)
|
||||
.FontSize(8);
|
||||
}
|
||||
|
||||
// Show any alarm bits that fired during this phase.
|
||||
if (phase.ErrorBits?.Count > 0)
|
||||
{
|
||||
table.Cell().ColumnSpan(6)
|
||||
.Background(Colors.Orange.Lighten4)
|
||||
.Padding(3)
|
||||
.Text($" ⚠ Error bits: {string.Join(", ", phase.ErrorBits)}")
|
||||
.FontSize(8).FontColor(Colors.Orange.Darken3);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Renders measurement charts for each parameter that has sample data.</summary>
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Sanitises a file name by replacing invalid characters with underscores.</summary>
|
||||
private static string SanitiseFileName(string name)
|
||||
{
|
||||
foreach (char c in Path.GetInvalidFileNameChars())
|
||||
|
||||
264
Services/Impl/ReportChartRenderer.cs
Normal file
264
Services/Impl/ReportChartRenderer.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using HC_APTBS.Models;
|
||||
|
||||
namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates SVG chart markup for embedding in QuestPDF reports via the
|
||||
/// <c>.Svg()</c> API. All rendering is done as raw SVG strings — no
|
||||
/// SkiaSharp dependency required at PDF-generation time.
|
||||
/// </summary>
|
||||
internal static class ReportChartRenderer
|
||||
{
|
||||
// ── Layout constants (SVG user-units, mapped to points in the PDF) ────
|
||||
|
||||
private const float LeftMargin = 48f;
|
||||
private const float RightMargin = 12f;
|
||||
private const float TopMargin = 22f;
|
||||
private const float BottomMargin = 24f;
|
||||
private const float LegendHeight = 14f;
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Produces an SVG string depicting a line chart of individual
|
||||
/// measurement samples with a horizontal target line, shaded tolerance
|
||||
/// band, average line, and axis labels.
|
||||
/// </summary>
|
||||
/// <param name="width">Chart width in points.</param>
|
||||
/// <param name="height">Chart height in points.</param>
|
||||
/// <param name="samples">Individual timestamped measurements.</param>
|
||||
/// <param name="targetValue">Expected / setpoint value.</param>
|
||||
/// <param name="tolerance">Acceptable deviation (±).</param>
|
||||
/// <param name="average">Computed average of all samples.</param>
|
||||
/// <param name="passed">Whether the parameter passed the tolerance check.</param>
|
||||
/// <param name="label">Chart title label (e.g. "1000 RPM 40°C — QDelivery").</param>
|
||||
/// <returns>A complete SVG document string.</returns>
|
||||
public static string RenderMeasurementChart(
|
||||
float width,
|
||||
float height,
|
||||
IReadOnlyList<MeasurementSample> samples,
|
||||
double targetValue,
|
||||
double tolerance,
|
||||
double average,
|
||||
bool passed,
|
||||
string label)
|
||||
{
|
||||
if (samples.Count < 2)
|
||||
return RenderInsufficientData(width, height, label);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(SvgHeader(width, height));
|
||||
|
||||
// Plot area bounds.
|
||||
float plotLeft = LeftMargin;
|
||||
float plotRight = width - RightMargin;
|
||||
float plotTop = TopMargin + LegendHeight;
|
||||
float plotBottom = height - BottomMargin;
|
||||
float plotW = plotRight - plotLeft;
|
||||
float plotH = plotBottom - plotTop;
|
||||
|
||||
// Y-axis range: encompass all samples and the tolerance band, with padding.
|
||||
double minVal = samples.Min(s => s.Value);
|
||||
double maxVal = samples.Max(s => s.Value);
|
||||
double bandLo = targetValue - tolerance;
|
||||
double bandHi = targetValue + tolerance;
|
||||
minVal = Math.Min(minVal, bandLo);
|
||||
maxVal = Math.Max(maxVal, bandHi);
|
||||
double range = maxVal - minVal;
|
||||
if (range < 0.001) range = 1.0;
|
||||
double padding = range * 0.12;
|
||||
double yMin = minVal - padding;
|
||||
double yMax = maxVal + padding;
|
||||
double yRange = yMax - yMin;
|
||||
|
||||
// ── Background & border ──────────────────────────────────────────
|
||||
|
||||
sb.AppendLine(Rect(0, 0, width, height, "#FFFFFF", null, 0));
|
||||
sb.AppendLine(Rect(plotLeft, plotTop, plotW, plotH, null, Col(ReportTheme.ChartGrid), 1));
|
||||
|
||||
// ── Grid lines ───────────────────────────────────────────────────
|
||||
|
||||
const int gridCount = 5;
|
||||
double gridStep = yRange / gridCount;
|
||||
for (int i = 0; i <= gridCount; i++)
|
||||
{
|
||||
double val = yMin + gridStep * i;
|
||||
float y = ValueToY(val, plotTop, plotH, yMin, yRange);
|
||||
sb.AppendLine(Line(plotLeft, y, plotRight, y, Col(ReportTheme.ChartGrid), 0.5f, null));
|
||||
|
||||
string text = val.ToString("F1", CultureInfo.InvariantCulture);
|
||||
sb.AppendLine(Text(plotLeft - 4f, y + 3f, text, Col(ReportTheme.AxisLabel), 7f, "end"));
|
||||
}
|
||||
|
||||
// ── Tolerance band ───────────────────────────────────────────────
|
||||
|
||||
float bandTopY = Math.Max(ValueToY(bandHi, plotTop, plotH, yMin, yRange), plotTop);
|
||||
float bandBotY = Math.Min(ValueToY(bandLo, plotTop, plotH, yMin, yRange), plotBottom);
|
||||
sb.AppendLine(Rect(plotLeft, bandTopY, plotW, bandBotY - bandTopY,
|
||||
ColAlpha(ReportTheme.ToleranceBand), null, 0));
|
||||
|
||||
// ── Target line (dashed amber) ───────────────────────────────────
|
||||
|
||||
float targetY = ValueToY(targetValue, plotTop, plotH, yMin, yRange);
|
||||
sb.AppendLine(Line(plotLeft, targetY, plotRight, targetY,
|
||||
Col(ReportTheme.TargetLine), 1.5f, "6,3"));
|
||||
|
||||
// ── Average line (dashed green/red) ──────────────────────────────
|
||||
|
||||
float avgY = ValueToY(average, plotTop, plotH, yMin, yRange);
|
||||
string avgCol = passed ? Col(ReportTheme.AvgPassLine) : Col(ReportTheme.AvgFailLine);
|
||||
sb.AppendLine(Line(plotLeft, avgY, plotRight, avgY, avgCol, 1.2f, "4,3"));
|
||||
|
||||
// ── Data polyline ────────────────────────────────────────────────
|
||||
|
||||
var points = new StringBuilder();
|
||||
for (int i = 0; i < samples.Count; i++)
|
||||
{
|
||||
float x = plotLeft + (plotW * i / (samples.Count - 1));
|
||||
float y = ValueToY(samples[i].Value, plotTop, plotH, yMin, yRange);
|
||||
if (i > 0) points.Append(' ');
|
||||
points.Append(F(x)).Append(',').Append(F(y));
|
||||
}
|
||||
|
||||
sb.Append($"<polyline points=\"{points}\" fill=\"none\" ")
|
||||
.Append($"stroke=\"{Col(ReportTheme.SampleLine)}\" stroke-width=\"1.5\" ")
|
||||
.AppendLine($"stroke-linejoin=\"round\"/>");
|
||||
|
||||
// ── Sample dots (only if ≤ 60 samples) ──────────────────────────
|
||||
|
||||
if (samples.Count <= 60)
|
||||
{
|
||||
for (int i = 0; i < samples.Count; i++)
|
||||
{
|
||||
float x = plotLeft + (plotW * i / (samples.Count - 1));
|
||||
float y = ValueToY(samples[i].Value, plotTop, plotH, yMin, yRange);
|
||||
sb.AppendLine($"<circle cx=\"{F(x)}\" cy=\"{F(y)}\" r=\"2\" " +
|
||||
$"fill=\"{Col(ReportTheme.SampleLine)}\"/>");
|
||||
}
|
||||
}
|
||||
|
||||
// ── X-axis labels ────────────────────────────────────────────────
|
||||
|
||||
int xLabelStep = Math.Max(1, samples.Count / 8);
|
||||
for (int i = 0; i < samples.Count; i += xLabelStep)
|
||||
{
|
||||
float x = plotLeft + (plotW * i / (samples.Count - 1));
|
||||
sb.AppendLine(Text(x, plotBottom + 12f, (i + 1).ToString(),
|
||||
Col(ReportTheme.AxisLabel), 7f, "middle"));
|
||||
}
|
||||
|
||||
// ── Title label (top-left) ───────────────────────────────────────
|
||||
|
||||
sb.AppendLine(Text(plotLeft, TopMargin, EscapeXml(label), "#1B2A4A", 9f, "start"));
|
||||
|
||||
// ── Legend (top-right) ────────────────────────────────────────────
|
||||
|
||||
string targetText = $"Target: {targetValue.ToString("F2", CultureInfo.InvariantCulture)}";
|
||||
string avgText = $"Avg: {average.ToString("F2", CultureInfo.InvariantCulture)}";
|
||||
sb.AppendLine(Text(plotRight - 80f, TopMargin, targetText,
|
||||
Col(ReportTheme.TargetLine), 7f, "start"));
|
||||
sb.AppendLine(Text(plotRight, TopMargin, avgText, avgCol, 7f, "end"));
|
||||
|
||||
sb.AppendLine("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces an SVG string depicting a large PASS/FAIL verdict badge.
|
||||
/// </summary>
|
||||
public static string RenderVerdictBadge(float width, float height, bool passed)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(SvgHeader(width, height));
|
||||
|
||||
string bgColor = passed ? "#2E7D32" : "#C62828";
|
||||
sb.AppendLine($"<rect x=\"2\" y=\"2\" width=\"{F(width - 4)}\" height=\"{F(height - 4)}\" " +
|
||||
$"rx=\"8\" ry=\"8\" fill=\"{bgColor}\"/>");
|
||||
|
||||
string text = passed ? "PASS" : "FAIL";
|
||||
float fontSize = height * 0.45f;
|
||||
sb.AppendLine($"<text x=\"{F(width / 2)}\" y=\"{F(height / 2 + fontSize * 0.35f)}\" " +
|
||||
$"font-family=\"Arial\" font-weight=\"bold\" font-size=\"{F(fontSize)}\" " +
|
||||
$"fill=\"white\" text-anchor=\"middle\">{text}</text>");
|
||||
|
||||
sb.AppendLine("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Maps a data value to the Y coordinate (inverted — higher values = lower Y).</summary>
|
||||
private static float ValueToY(double value, float plotTop, float plotH, double yMin, double yRange)
|
||||
=> plotTop + (float)((1.0 - (value - yMin) / yRange) * plotH);
|
||||
|
||||
/// <summary>Renders a placeholder SVG when fewer than 2 samples are available.</summary>
|
||||
private static string RenderInsufficientData(float width, float height, string label)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(SvgHeader(width, height));
|
||||
sb.AppendLine(Rect(0, 0, width, height, "#F5F7FA", Col(ReportTheme.ChartGrid), 1));
|
||||
sb.AppendLine(Text(12f, 18f, EscapeXml(label), "#1B2A4A", 9f, "start"));
|
||||
sb.AppendLine(Text(width / 2, height / 2 + 4f,
|
||||
"Insufficient sample data for chart", Col(ReportTheme.AxisLabel), 10f, "middle"));
|
||||
sb.AppendLine("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── SVG primitives ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>SVG document header.</summary>
|
||||
private static string SvgHeader(float w, float h)
|
||||
=> $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{F(w)}\" height=\"{F(h)}\" " +
|
||||
$"viewBox=\"0 0 {F(w)} {F(h)}\">";
|
||||
|
||||
/// <summary>SVG rect element with optional fill and/or stroke.</summary>
|
||||
private static string Rect(float x, float y, float w, float h,
|
||||
string? fill, string? stroke, float strokeWidth)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"<rect x=\"{F(x)}\" y=\"{F(y)}\" width=\"{F(w)}\" height=\"{F(h)}\"");
|
||||
sb.Append(fill != null ? $" fill=\"{fill}\"" : " fill=\"none\"");
|
||||
if (stroke != null) sb.Append($" stroke=\"{stroke}\" stroke-width=\"{F(strokeWidth)}\"");
|
||||
sb.Append("/>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>SVG line element with optional dash array.</summary>
|
||||
private static string Line(float x1, float y1, float x2, float y2,
|
||||
string stroke, float strokeWidth, string? dashArray)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"<line x1=\"{F(x1)}\" y1=\"{F(y1)}\" x2=\"{F(x2)}\" y2=\"{F(y2)}\" ");
|
||||
sb.Append($"stroke=\"{stroke}\" stroke-width=\"{F(strokeWidth)}\"");
|
||||
if (dashArray != null) sb.Append($" stroke-dasharray=\"{dashArray}\"");
|
||||
sb.Append("/>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>SVG text element.</summary>
|
||||
private static string Text(float x, float y, string content,
|
||||
string fill, float fontSize, string anchor)
|
||||
=> $"<text x=\"{F(x)}\" y=\"{F(y)}\" font-family=\"Arial\" font-size=\"{F(fontSize)}\" " +
|
||||
$"fill=\"{fill}\" text-anchor=\"{anchor}\">{content}</text>";
|
||||
|
||||
/// <summary>Formats a float with invariant culture (dot decimal separator).</summary>
|
||||
private static string F(float v) => v.ToString("F1", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>Converts an SKColor to an SVG hex colour string.</summary>
|
||||
private static string Col(SkiaSharp.SKColor c) => $"#{c.Red:X2}{c.Green:X2}{c.Blue:X2}";
|
||||
|
||||
/// <summary>Converts an SKColor with alpha to an SVG rgba() string.</summary>
|
||||
private static string ColAlpha(SkiaSharp.SKColor c)
|
||||
=> $"rgba({c.Red},{c.Green},{c.Blue},{(c.Alpha / 255.0).ToString("F2", CultureInfo.InvariantCulture)})";
|
||||
|
||||
/// <summary>Escapes XML special characters in text content.</summary>
|
||||
private static string EscapeXml(string s)
|
||||
=> s.Replace("&", "&").Replace("<", "<").Replace(">", ">")
|
||||
.Replace("\"", """).Replace("\u2014", "-");
|
||||
}
|
||||
}
|
||||
106
Services/Impl/ReportTheme.cs
Normal file
106
Services/Impl/ReportTheme.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using SkiaSharp;
|
||||
|
||||
namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralised visual constants for the PDF test report.
|
||||
/// All colours, font sizes, spacing, and chart dimensions live here
|
||||
/// so the report look-and-feel can be adjusted in one place.
|
||||
/// </summary>
|
||||
internal static class ReportTheme
|
||||
{
|
||||
// ── Colour palette (hex strings for QuestPDF, SKColor for charts) ─────
|
||||
|
||||
/// <summary>Dark navy for section header bars.</summary>
|
||||
public const string HeaderNavy = "#1B2A4A";
|
||||
|
||||
/// <summary>Secondary grey for sub-headers and muted text.</summary>
|
||||
public const string HeaderGrey = "#4A4A4A";
|
||||
|
||||
/// <summary>Accent blue for highlights.</summary>
|
||||
public const string AccentBlue = "#2E5090";
|
||||
|
||||
/// <summary>Pass result — dark green text.</summary>
|
||||
public const string PassGreen = "#2E7D32";
|
||||
|
||||
/// <summary>Pass result — light green background.</summary>
|
||||
public const string PassGreenLight = "#E8F5E9";
|
||||
|
||||
/// <summary>Fail result — dark red text.</summary>
|
||||
public const string FailRed = "#C62828";
|
||||
|
||||
/// <summary>Fail result — light red background.</summary>
|
||||
public const string FailRedLight = "#FFEBEE";
|
||||
|
||||
/// <summary>Alternating table row stripe.</summary>
|
||||
public const string TableAltRow = "#F5F7FA";
|
||||
|
||||
/// <summary>Thin divider lines.</summary>
|
||||
public const string DividerLine = "#BDBDBD";
|
||||
|
||||
/// <summary>Error/warning row background.</summary>
|
||||
public const string WarningBg = "#FFF3E0";
|
||||
|
||||
/// <summary>Error/warning text.</summary>
|
||||
public const string WarningText = "#E65100";
|
||||
|
||||
// ── Chart-specific SKColors ───────────────────────────────────────────
|
||||
|
||||
/// <summary>Semi-transparent blue tolerance band.</summary>
|
||||
public static readonly SKColor ToleranceBand = new(46, 80, 144, 50);
|
||||
|
||||
/// <summary>Amber dashed target line.</summary>
|
||||
public static readonly SKColor TargetLine = new(255, 111, 0);
|
||||
|
||||
/// <summary>Blue data polyline.</summary>
|
||||
public static readonly SKColor SampleLine = new(21, 101, 192);
|
||||
|
||||
/// <summary>Light grey chart gridlines and border.</summary>
|
||||
public static readonly SKColor ChartGrid = new(224, 224, 224);
|
||||
|
||||
/// <summary>Chart background.</summary>
|
||||
public static readonly SKColor ChartBackground = SKColors.White;
|
||||
|
||||
/// <summary>Green average line (pass).</summary>
|
||||
public static readonly SKColor AvgPassLine = new(46, 125, 50);
|
||||
|
||||
/// <summary>Red average line (fail).</summary>
|
||||
public static readonly SKColor AvgFailLine = new(198, 40, 40);
|
||||
|
||||
/// <summary>Chart axis label colour.</summary>
|
||||
public static readonly SKColor AxisLabel = new(97, 97, 97);
|
||||
|
||||
// ── Font sizes (points) ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Report title.</summary>
|
||||
public const float TitleSize = 16f;
|
||||
|
||||
/// <summary>Section header text (e.g. "PUMP IDENTIFICATION").</summary>
|
||||
public const float SectionHeaderSize = 11f;
|
||||
|
||||
/// <summary>Body / table cell text.</summary>
|
||||
public const float BodySize = 9f;
|
||||
|
||||
/// <summary>Small captions and chart labels.</summary>
|
||||
public const float CaptionSize = 7f;
|
||||
|
||||
/// <summary>Footer text.</summary>
|
||||
public const float FooterSize = 7f;
|
||||
|
||||
// ── Spacing (points) ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Gap between major sections.</summary>
|
||||
public const float SectionGap = 10f;
|
||||
|
||||
/// <summary>Gap between sub-sections (e.g. chart caption).</summary>
|
||||
public const float SubsectionGap = 6f;
|
||||
|
||||
/// <summary>Table cell padding.</summary>
|
||||
public const float CellPad = 3f;
|
||||
|
||||
// ── Chart dimensions (points) ─────────────────────────────────────────
|
||||
|
||||
/// <summary>Chart render height inside the PDF.</summary>
|
||||
public const float ChartHeight = 160f;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user