From 3c1ab76984ff85f29094d01c921d2f3f2bee3a36 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 17:21:11 +0700 Subject: [PATCH 1/5] Add shared native IO FAT report layout engine --- Services/IoTesting/IoFatReportLayoutEngine.cs | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 Services/IoTesting/IoFatReportLayoutEngine.cs diff --git a/Services/IoTesting/IoFatReportLayoutEngine.cs b/Services/IoTesting/IoFatReportLayoutEngine.cs new file mode 100644 index 0000000..3defa40 --- /dev/null +++ b/Services/IoTesting/IoFatReportLayoutEngine.cs @@ -0,0 +1,435 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using System.Text; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +internal enum IoFatReportFontKind +{ + Regular, + Bold, + Mono +} + +internal readonly record struct IoFatReportColor(byte R, byte G, byte B) +{ + public static IoFatReportColor FromHex(string hex) + { + var value = hex.StartsWith("#", StringComparison.Ordinal) ? hex[1..] : hex; + if (value.Length != 6) + throw new ArgumentException("Report color must be a six-digit RGB hex value.", nameof(hex)); + return new IoFatReportColor( + Convert.ToByte(value[..2], 16), + Convert.ToByte(value.Substring(2, 2), 16), + Convert.ToByte(value.Substring(4, 2), 16)); + } +} + +internal abstract record IoFatReportCommand; +internal sealed record IoFatReportRectCommand(double X, double TopY, double Width, double Height, double Radius, IoFatReportColor Fill, IoFatReportColor Stroke, double StrokeThickness) : IoFatReportCommand; +internal sealed record IoFatReportLineCommand(double X1, double Y1, double X2, double Y2, IoFatReportColor Stroke, double StrokeThickness) : IoFatReportCommand; +internal sealed record IoFatReportTextCommand(double X, double BaselineY, double Width, string Text, IoFatReportFontKind Font, double FontSize, IoFatReportColor Color) : IoFatReportCommand; +internal sealed record IoFatReportPagePlan(int PageNumber, double Width, double Height, IReadOnlyList Commands); +internal sealed record IoFatReportLayoutPlan(string ProjectId, DateTimeOffset CreatedAt, bool Draft, IReadOnlyList Pages); + +/// +/// Single layout source for the native PDF writer and WPF FixedDocument preview. +/// Coordinates are expressed in PDF points. +/// +internal static class IoFatReportLayoutEngine +{ + public const double PageWidth = 842d; + public const double PageHeight = 595d; + private const double Margin = 30d; + private const double HeaderBottom = 500d; + private const double ContentTop = 484d; + private const double ContentBottom = 55d; + private const double ContentWidth = PageWidth - (Margin * 2d); + + private static readonly IoFatReportColor BrandNavy = Color("0F172A"); + private static readonly IoFatReportColor BrandBlue = Color("2563EB"); + private static readonly IoFatReportColor SoftBlue = Color("EFF6FF"); + private static readonly IoFatReportColor SoftSlate = Color("F8FAFC"); + private static readonly IoFatReportColor Border = Color("DDE7F3"); + private static readonly IoFatReportColor SoftLine = Color("EEF2F7"); + private static readonly IoFatReportColor Muted = Color("64748B"); + private static readonly IoFatReportColor Ink = Color("111827"); + private static readonly IoFatReportColor White = Color("FFFFFF"); + private static readonly IoFatReportColor Pass = Color("15803D"); + private static readonly IoFatReportColor Attention = Color("B45309"); + private static readonly IoFatReportColor Fail = Color("B91C1C"); + private static readonly IoFatReportColor SoftPass = Color("F0FDF4"); + private static readonly IoFatReportColor SoftAttention = Color("FFFBEB"); + private static readonly IoFatReportColor SoftFail = Color("FEF2F2"); + + public static IoFatReportLayoutPlan Build(IoTestProject project, DateTimeOffset created, bool draft = false) + { + ArgumentNullException.ThrowIfNull(project); + return new Builder(project, created, draft).Render(); + } + + private sealed class Builder + { + private readonly IoTestProject _project; + private readonly DateTimeOffset _created; + private readonly bool _draft; + private readonly List _pages = new(); + private PageBuilder _page = null!; + private double _cursorY; + + public Builder(IoTestProject project, DateTimeOffset created, bool draft) + { + _project = project; + _created = created; + _draft = draft; + } + + public IoFatReportLayoutPlan Render() + { + NewPage(); + DrawExecutiveSummary(); + foreach (var ied in _project.Ieds) + DrawIedSection(ied); + if (_project.Ieds.Count == 0) + DrawEmptyProjectNotice(); + + var totalPages = _pages.Count; + for (var index = 0; index < totalPages; index++) + DrawPageChrome(_pages[index], index + 1, totalPages); + + return new IoFatReportLayoutPlan( + _project.ProjectId, + _created, + _draft, + _pages.Select((page, index) => new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, page.Commands.ToArray())).ToArray()); + } + + private void NewPage() + { + _page = new PageBuilder(); + _pages.Add(_page); + _cursorY = ContentTop; + } + + private void Ensure(double requiredHeight) + { + if (_cursorY - requiredHeight < ContentBottom) + NewPage(); + } + + private void DrawPageChrome(PageBuilder page, int pageNumber, int totalPages) + { + var counts = Counts(_project); + var tone = _draft ? "DRAFT / LIVE" : ResolveOverallTone(counts); + var toneColor = _draft ? Attention : ResolveToneColor(tone); + var toneBackground = _draft ? SoftAttention : ResolveToneBackground(tone); + var scope = _project.Ieds.Count == 1 ? _project.Ieds[0].IedName : _project.ProjectId; + + page.Line(Margin, HeaderBottom, PageWidth - Margin, HeaderBottom, Border, 0.8d); + page.Text(Margin, 562d, 390d, "ARSAS - IEC 61850 IO LIST FAT", IoFatReportFontKind.Bold, 7.3d, Muted); + page.Text(Margin, 541d, 500d, "IEC 61850 FAT Evidence Report", IoFatReportFontKind.Bold, 20.4d, BrandNavy); + page.Text(Margin, 523d, 590d, "Ordered OFF > ON > OFF verification with relay timestamps, quality and acquisition source.", IoFatReportFontKind.Regular, 7.3d, Muted); + + const double cardWidth = 142d; + const double cardHeight = 58d; + var cardX = PageWidth - Margin - cardWidth; + const double cardTop = 566d; + page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 5d, toneBackground, toneColor, 0.8d); + page.Text(cardX + 10d, cardTop - 15d, cardWidth - 20d, _draft ? "PREVIEW STATUS" : "EVIDENCE STATUS", IoFatReportFontKind.Bold, 6.2d, Muted); + page.Text(cardX + 10d, cardTop - 35d, cardWidth - 20d, tone, IoFatReportFontKind.Bold, 16.2d, toneColor); + page.Text(cardX + 10d, cardTop - 49d, cardWidth - 20d, Truncate(scope, 28), IoFatReportFontKind.Regular, 5.8d, Muted); + + page.Line(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); + page.Text(Margin, 24d, 650d, $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | Project {_project.ProjectId} | Workbook SHA256 {ShortHash(_project.SourceWorkbookSha256)}", IoFatReportFontKind.Regular, 6.2d, Muted); + page.Text(PageWidth - Margin - 72d, 24d, 72d, $"Page {pageNumber} / {totalPages}", IoFatReportFontKind.Regular, 6.2d, Muted); + } + + private void DrawExecutiveSummary() + { + var counts = Counts(_project); + var enabledSignals = _project.Ieds.Sum(ied => ReportPoints(ied).Count); + const double height = 104d; + Ensure(height + 12d); + + _page.RoundRect(Margin, _cursorY, ContentWidth, height, 5d, SoftSlate, Border, 0.8d); + _page.Text(Margin + 13d, _cursorY - 19d, ContentWidth - 26d, "Project Evidence Summary", IoFatReportFontKind.Bold, 11.2d, BrandNavy); + _page.Text(Margin + 13d, _cursorY - 36d, ContentWidth - 26d, $"{Clean(_project.ProjectName)} | {Clean(_project.ProjectId)}", IoFatReportFontKind.Bold, 8.2d, Ink); + _page.Text(Margin + 13d, _cursorY - 51d, ContentWidth - 26d, $"Source: {Clean(_project.SourceWorkbookName)}", IoFatReportFontKind.Regular, 6.8d, Muted); + _page.Text(Margin + 13d, _cursorY - 64d, ContentWidth - 26d, $"Workbook SHA-256: {Clean(_project.SourceWorkbookSha256)}", IoFatReportFontKind.Mono, 5.8d, Muted); + + const double metricTop = 86d; + const double gap = 7d; + var metricWidth = (ContentWidth - 26d - (gap * 5d)) / 6d; + var x = Margin + 13d; + DrawMetric(x, _cursorY - metricTop, metricWidth, "IED", _project.Ieds.Count.ToString(CultureInfo.InvariantCulture), BrandBlue, SoftBlue); + x += metricWidth + gap; + DrawMetric(x, _cursorY - metricTop, metricWidth, "TEST POINTS", enabledSignals.ToString(CultureInfo.InvariantCulture), BrandNavy, White); + x += metricWidth + gap; + DrawMetric(x, _cursorY - metricTop, metricWidth, "PASS", counts.Passed.ToString(CultureInfo.InvariantCulture), Pass, SoftPass); + x += metricWidth + gap; + DrawMetric(x, _cursorY - metricTop, metricWidth, "REVIEW", counts.Review.ToString(CultureInfo.InvariantCulture), Attention, SoftAttention); + x += metricWidth + gap; + DrawMetric(x, _cursorY - metricTop, metricWidth, "FAIL", counts.Failed.ToString(CultureInfo.InvariantCulture), Fail, SoftFail); + x += metricWidth + gap; + DrawMetric(x, _cursorY - metricTop, metricWidth, "PENDING", counts.Pending.ToString(CultureInfo.InvariantCulture), Muted, White); + _cursorY -= height + 12d; + } + + private void DrawMetric(double x, double top, double width, string label, string value, IoFatReportColor color, IoFatReportColor background) + { + _page.RoundRect(x, top, width, 29d, 4d, background, Border, 0.55d); + _page.Text(x + 7d, top - 11d, width - 14d, label, IoFatReportFontKind.Bold, 5.3d, Muted); + _page.Text(x + 7d, top - 23d, width - 14d, value, IoFatReportFontKind.Bold, 9.2d, color); + } + + private void DrawIedSection(IoTestIedPlan ied) + { + var points = ReportPoints(ied); + Ensure(82d); + DrawIedHeader(ied, points, continued: false); + DrawTableHeader(); + + var rowNumber = 0; + foreach (var point in points) + { + rowNumber++; + var cells = BuildCells(point, rowNumber); + var rowHeight = EstimateRowHeight(cells); + if (_cursorY - rowHeight < ContentBottom) + { + NewPage(); + DrawIedHeader(ied, points, continued: true); + DrawTableHeader(); + } + DrawRow(cells, rowHeight); + } + + if (points.Count == 0) + { + Ensure(34d); + _page.RoundRect(Margin, _cursorY, ContentWidth, 28d, 4d, SoftSlate, Border, 0.6d); + _page.Text(Margin + 10d, _cursorY - 18d, ContentWidth - 20d, "No enabled IO-list test point is available for this IED.", IoFatReportFontKind.Regular, 7d, Muted); + _cursorY -= 38d; + } + _cursorY -= 11d; + } + + private void DrawIedHeader(IoTestIedPlan ied, IReadOnlyList points, bool continued) + { + var title = continued ? $"{ied.IedName} (continued)" : ied.IedName; + var passed = points.Count(point => point.Runtime.State == IoTestPointState.Passed); + var review = points.Count(point => point.Runtime.State == IoTestPointState.Review); + var failed = points.Count(point => point.Runtime.State == IoTestPointState.Failed); + var pending = Math.Max(0, points.Count - passed - review - failed); + const double height = 48d; + + _page.RoundRect(Margin, _cursorY, ContentWidth, height, 5d, White, Border, 0.7d); + _page.Rect(Margin, _cursorY, 4d, height, BrandBlue, BrandBlue, 0d); + _page.Text(Margin + 13d, _cursorY - 17d, 360d, Clean(title), IoFatReportFontKind.Bold, 10.2d, BrandNavy); + _page.Text(Margin + 13d, _cursorY - 33d, 520d, $"{Clean(ied.IpAddress)} | {Clean(ied.IedRole)} | {Clean(ied.Location)} | {Clean(ied.VoltageLevel)} | {Clean(ied.Switchgear)}", IoFatReportFontKind.Regular, 6.3d, Muted); + _page.Text(PageWidth - Margin - 244d, _cursorY - 18d, 232d, $"{points.Count} signals | {passed} PASS | {review} review | {failed} fail | {pending} pending", IoFatReportFontKind.Bold, 6.1d, BrandBlue); + _cursorY -= height + 7d; + } + + private void DrawTableHeader() + { + Ensure(22d); + var widths = ColumnWidths(); + var headers = new[] { "#", "Signal", "IEC 61850 reference", "Expected ON / OFF", "ON evidence", "OFF evidence", "Result", "Reason" }; + var x = Margin; + const double height = 19d; + for (var index = 0; index < headers.Length; index++) + { + _page.Rect(x, _cursorY, widths[index], height, SoftBlue, Border, 0.45d); + _page.Text(x + 4d, _cursorY - 12.5d, widths[index] - 8d, headers[index], IoFatReportFontKind.Bold, 5.55d, BrandBlue); + x += widths[index]; + } + _cursorY -= height; + } + + private void DrawRow(IReadOnlyList cells, double rowHeight) + { + var widths = ColumnWidths(); + var x = Margin; + for (var index = 0; index < cells.Count; index++) + { + var cell = cells[index]; + _page.Rect(x, _cursorY, widths[index], rowHeight, White, SoftLine, 0.35d); + var lines = WrapText(cell.Text, widths[index] - 8d, cell.FontSize, cell.MaxLines); + var y = _cursorY - 8.5d; + foreach (var line in lines) + { + _page.Text(x + 4d, y, widths[index] - 8d, line, cell.Font, cell.FontSize, cell.Color); + y -= cell.FontSize + 1.35d; + } + x += widths[index]; + } + _cursorY -= rowHeight; + } + + private static ReportCell[] BuildCells(IoTestPointPlan point, int rowNumber) + { + var stateColor = point.Runtime.State switch + { + IoTestPointState.Passed => Pass, + IoTestPointState.Failed => Fail, + IoTestPointState.Review => Attention, + _ => Muted + }; + return new[] + { + new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), IoFatReportFontKind.Mono, 5.35d, Ink, 1), + new ReportCell(point.SignalName, IoFatReportFontKind.Bold, 5.65d, Ink, 3), + new ReportCell(point.ObjectReference, IoFatReportFontKind.Mono, 5.15d, Ink, 3), + new ReportCell($"ON {point.ExpectedOnText} ({point.ExpectedOnRaw})\nOFF {point.ExpectedOffText} ({point.ExpectedOffRaw})", IoFatReportFontKind.Regular, 5.35d, Ink, 3), + new ReportCell(EvidenceText(point.Runtime.OnEvidence), IoFatReportFontKind.Regular, 5.05d, point.Runtime.OnEvidence == null ? Muted : Pass, 4), + new ReportCell(EvidenceText(point.Runtime.OffEvidence), IoFatReportFontKind.Regular, 5.05d, point.Runtime.OffEvidence == null ? Muted : Pass, 4), + new ReportCell(point.Runtime.State.ToString().ToUpperInvariant(), IoFatReportFontKind.Bold, 5.45d, stateColor, 2), + new ReportCell(point.Runtime.StatusReason, IoFatReportFontKind.Regular, 5.2d, Ink, 3) + }; + } + + private static double EstimateRowHeight(IReadOnlyList cells) + { + var widths = ColumnWidths(); + var maximum = 1; + for (var index = 0; index < cells.Count; index++) + maximum = Math.Max(maximum, WrapText(cells[index].Text, widths[index] - 8d, cells[index].FontSize, cells[index].MaxLines).Count); + return Math.Max(18d, 8d + (maximum * 7.15d)); + } + + private static double[] ColumnWidths() => new[] { 24d, 108d, 165d, 78d, 119d, 119d, 52d, 117d }; + + private void DrawEmptyProjectNotice() + { + Ensure(48d); + _page.RoundRect(Margin, _cursorY, ContentWidth, 42d, 5d, SoftAttention, Border, 0.7d); + _page.Text(Margin + 12d, _cursorY - 25d, ContentWidth - 24d, "No IED test plan is present in this project.", IoFatReportFontKind.Bold, 9d, Attention); + _cursorY -= 52d; + } + } + + private sealed record ReportCell(string Text, IoFatReportFontKind Font, double FontSize, IoFatReportColor Color, int MaxLines); + + private sealed class PageBuilder + { + public List Commands { get; } = new(); + public void Text(double x, double baselineY, double width, string text, IoFatReportFontKind font, double size, IoFatReportColor color) + { + var safe = SanitizeReportText(text); + if (safe.Length > 0) + Commands.Add(new IoFatReportTextCommand(x, baselineY, Math.Max(4d, width), safe, font, size, color)); + } + public void Line(double x1, double y1, double x2, double y2, IoFatReportColor stroke, double width) => Commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, width)); + public void Rect(double x, double top, double width, double height, IoFatReportColor fill, IoFatReportColor stroke, double lineWidth) => Commands.Add(new IoFatReportRectCommand(x, top, width, height, 0d, fill, stroke, lineWidth)); + public void RoundRect(double x, double top, double width, double height, double radius, IoFatReportColor fill, IoFatReportColor stroke, double lineWidth) => Commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, lineWidth)); + } + + private readonly record struct ProjectCounts(int Passed, int Review, int Failed, int Pending); + private static List ReportPoints(IoTestIedPlan ied) => ied.TestPoints.Where(point => point.TestEnabled).ToList(); + + private static ProjectCounts Counts(IoTestProject project) + { + var points = project.Ieds.SelectMany(ReportPoints).ToList(); + var passed = points.Count(point => point.Runtime.State == IoTestPointState.Passed); + var review = points.Count(point => point.Runtime.State == IoTestPointState.Review); + var failed = points.Count(point => point.Runtime.State == IoTestPointState.Failed); + return new ProjectCounts(passed, review, failed, Math.Max(0, points.Count - passed - review - failed)); + } + + private static string ResolveOverallTone(ProjectCounts counts) + { + if (counts.Failed > 0) return "FAILED"; + if (counts.Review > 0) return "REVIEW"; + if (counts.Pending > 0) return "IN PROGRESS"; + return counts.Passed > 0 ? "PASSED" : "NOT STARTED"; + } + + private static IoFatReportColor ResolveToneColor(string tone) => tone switch { "PASSED" => Pass, "FAILED" => Fail, "REVIEW" => Attention, _ => BrandBlue }; + private static IoFatReportColor ResolveToneBackground(string tone) => tone switch { "PASSED" => SoftPass, "FAILED" => SoftFail, "REVIEW" => SoftAttention, _ => SoftBlue }; + + private static string EvidenceText(IoTestTransitionEvidence? evidence) + { + if (evidence == null) return "-"; + var ied = evidence.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture) ?? "not supplied"; + return $"IED {ied}\nARSAS {evidence.CapturedAt:yyyy-MM-dd HH:mm:ss.fff zzz}\n{evidence.RawValue} | {evidence.Quality} | {evidence.AcquisitionSource}"; + } + + private static IReadOnlyList WrapText(string? value, double width, double fontSize, int maxLines) + { + var input = (value ?? string.Empty).Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + if (string.IsNullOrWhiteSpace(input)) return new[] { "-" }; + var charsPerLine = Math.Max(7, (int)Math.Floor(width / Math.Max(2.4d, fontSize * 0.49d))); + var lines = new List(); + var truncated = false; + foreach (var paragraphValue in input.Split('\n')) + { + var paragraph = SanitizeReportText(paragraphValue); + if (paragraph.Length == 0) paragraph = "-"; + var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var current = new StringBuilder(); + foreach (var originalWord in words) + { + var word = originalWord; + while (word.Length > charsPerLine) + { + if (current.Length > 0) + { + lines.Add(current.ToString()); current.Clear(); + if (lines.Count >= maxLines) { truncated = true; break; } + } + lines.Add(word[..charsPerLine]); word = word[charsPerLine..]; + if (lines.Count >= maxLines) { truncated = word.Length > 0; break; } + } + if (lines.Count >= maxLines) break; + if (current.Length == 0) current.Append(word); + else if (current.Length + 1 + word.Length <= charsPerLine) current.Append(' ').Append(word); + else + { + lines.Add(current.ToString()); current.Clear().Append(word); + if (lines.Count >= maxLines) { truncated = true; break; } + } + } + if (lines.Count >= maxLines) break; + if (current.Length > 0) lines.Add(current.ToString()); + if (lines.Count >= maxLines) { truncated = true; break; } + } + if (lines.Count == 0) lines.Add("-"); + if (lines.Count > maxLines) lines = lines.Take(maxLines).ToList(); + if (truncated && lines[^1].Length > 3) lines[^1] = lines[^1][..Math.Max(0, lines[^1].Length - 3)] + "..."; + return lines; + } + + internal static string SanitizeReportText(string? value) + { + var normalized = (value ?? string.Empty).Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal).Trim(); + if (string.IsNullOrWhiteSpace(normalized)) return "-"; + var builder = new StringBuilder(normalized.Length); + foreach (var character in normalized) + { + builder.Append(character switch + { + '\u2013' or '\u2014' or '\u2212' => '-', + '\u2192' => '>', + '\u2190' => '<', + '\u00B7' => '|', + '\u00A0' => ' ', + >= ' ' and <= '~' => character, + _ => ' ' + }); + } + return builder.ToString().Trim(); + } + + private static string Clean(string? value) + { + var normalized = (value ?? string.Empty).Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal).Trim(); + return string.IsNullOrWhiteSpace(normalized) ? "-" : normalized; + } + private static string ShortHash(string? value) { var clean = Clean(value); return clean.Length <= 16 ? clean : clean[..16]; } + private static string Truncate(string? value, int maximum) { var clean = Clean(value); return clean.Length <= maximum || maximum <= 3 ? clean : clean[..(maximum - 3)] + "..."; } + private static IoFatReportColor Color(string hex) => IoFatReportColor.FromHex(hex); +} From ac520456c7c8023fd203b1164e758b5aceab1f24 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 17:24:39 +0700 Subject: [PATCH 2/5] Share native report layout between PDF and WPF preview --- Services/IoTesting/IoFatNativePdfWriter.cs | 210 ++++++ Services/IoTesting/IoFatPdfReportService.cs | 713 +----------------- .../IoFatReportPreviewDocumentBuilder.cs | 158 ++++ 3 files changed, 381 insertions(+), 700 deletions(-) create mode 100644 Services/IoTesting/IoFatNativePdfWriter.cs create mode 100644 Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs diff --git a/Services/IoTesting/IoFatNativePdfWriter.cs b/Services/IoTesting/IoFatNativePdfWriter.cs new file mode 100644 index 0000000..f7149a7 --- /dev/null +++ b/Services/IoTesting/IoFatNativePdfWriter.cs @@ -0,0 +1,210 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using System.Text; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Dependency-free PDF 1.4 serializer for the shared IO FAT report layout plan. +/// The WPF preview consumes the same command stream through +/// IoFatReportPreviewDocumentBuilder. +/// +internal static class IoFatNativePdfWriter +{ + public static byte[] Build(IoFatReportLayoutPlan layout, IoTestProject project) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(project); + if (layout.Pages.Count == 0) + throw new InvalidOperationException("At least one PDF page is required."); + + var objects = new List(); + int AddObject(string body) + { + objects.Add(Encoding.ASCII.GetBytes(body)); + return objects.Count; + } + + var catalogId = AddObject("<< /Type /Catalog /Pages 2 0 R >>"); + var pagesId = AddObject("__PAGES__"); + var fontRegularId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); + var fontBoldId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"); + var fontMonoId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>"); + var pageIds = new List(); + + foreach (var page in layout.Pages) + { + var content = BuildPageContent(page); + var contentBytes = Encoding.ASCII.GetBytes(content); + var contentId = AddObject($"<< /Length {contentBytes.Length.ToString(CultureInfo.InvariantCulture)} >>\nstream\n{content}endstream"); + var pageId = AddObject( + $"<< /Type /Page /Parent {pagesId} 0 R /MediaBox [0 0 {Number(page.Width)} {Number(page.Height)}] " + + $"/Resources << /Font << /F1 {fontRegularId} 0 R /F2 {fontBoldId} 0 R /F3 {fontMonoId} 0 R >> >> " + + $"/Contents {contentId} 0 R >>"); + pageIds.Add(pageId); + } + + var title = $"{project.ProjectName} - ARSAS IO FAT Evidence Report"; + var infoId = AddObject( + $"<< /Title ({EscapeLiteral(IoFatReportLayoutEngine.SanitizeReportText(title))}) " + + "/Author (ARSAS) " + + "/Creator (ARSAS Native PDF and FixedDocument Engine, adapted from ARIEC60870) " + + "/Producer (ARSAS Native PDF Engine) " + + $"/CreationDate ({PdfDate(layout.CreatedAt)}) >>"); + + objects[pagesId - 1] = Encoding.ASCII.GetBytes( + $"<< /Type /Pages /Kids [{string.Join(" ", pageIds.Select(id => $"{id} 0 R"))}] /Count {pageIds.Count} >>"); + + using var stream = new MemoryStream(); + WriteAscii(stream, "%PDF-1.4\n%ARSAS native PDF\n"); + var offsets = new long[objects.Count + 1]; + for (var index = 0; index < objects.Count; index++) + { + offsets[index + 1] = stream.Position; + WriteAscii(stream, $"{index + 1} 0 obj\n"); + stream.Write(objects[index], 0, objects[index].Length); + WriteAscii(stream, "\nendobj\n"); + } + + var xrefOffset = stream.Position; + WriteAscii(stream, $"xref\n0 {objects.Count + 1}\n"); + WriteAscii(stream, "0000000000 65535 f \n"); + for (var index = 1; index < offsets.Length; index++) + WriteAscii(stream, offsets[index].ToString("0000000000", CultureInfo.InvariantCulture) + " 00000 n \n"); + + WriteAscii( + stream, + $"trailer\n<< /Size {objects.Count + 1} /Root {catalogId} 0 R /Info {infoId} 0 R >>\n" + + $"startxref\n{xrefOffset.ToString(CultureInfo.InvariantCulture)}\n%%EOF\n"); + return stream.ToArray(); + } + + private static string BuildPageContent(IoFatReportPagePlan page) + { + var output = new StringBuilder(32_000); + foreach (var command in page.Commands) + { + switch (command) + { + case IoFatReportTextCommand text: + WriteText(output, text); + break; + case IoFatReportLineCommand line: + WriteLine(output, line); + break; + case IoFatReportRectCommand rect when rect.Radius > 0d: + WriteRoundRect(output, rect); + break; + case IoFatReportRectCommand rect: + WriteRect(output, rect); + break; + } + } + return output.ToString(); + } + + private static void WriteText(StringBuilder output, IoFatReportTextCommand command) + { + var safe = IoFatReportLayoutEngine.SanitizeReportText(command.Text); + if (safe.Length == 0) + return; + output.Append("BT ") + .Append(Fill(command.Color)).Append(' ') + .Append('/').Append(ResourceName(command.Font)).Append(' ').Append(Number(command.FontSize)).Append(" Tf ") + .Append("1 0 0 1 ").Append(Number(command.X)).Append(' ').Append(Number(command.BaselineY)).Append(" Tm ") + .Append('(').Append(EscapeLiteral(safe)).Append(") Tj ET\n"); + } + + private static void WriteLine(StringBuilder output, IoFatReportLineCommand command) + { + output.Append(Number(command.StrokeThickness)).Append(" w ") + .Append(Stroke(command.Stroke)).Append(' ') + .Append(Number(command.X1)).Append(' ').Append(Number(command.Y1)).Append(" m ") + .Append(Number(command.X2)).Append(' ').Append(Number(command.Y2)).Append(" l S\n"); + } + + private static void WriteRect(StringBuilder output, IoFatReportRectCommand command) + { + var y = command.TopY - command.Height; + if (command.StrokeThickness <= 0d || command.Fill.Equals(command.Stroke)) + { + output.Append(Fill(command.Fill)).Append(' ') + .Append(Number(command.X)).Append(' ').Append(Number(y)).Append(' ') + .Append(Number(command.Width)).Append(' ').Append(Number(command.Height)).Append(" re f\n"); + return; + } + + output.Append(Number(command.StrokeThickness)).Append(" w ") + .Append(Fill(command.Fill)).Append(' ') + .Append(Stroke(command.Stroke)).Append(' ') + .Append(Number(command.X)).Append(' ').Append(Number(y)).Append(' ') + .Append(Number(command.Width)).Append(' ').Append(Number(command.Height)).Append(" re B\n"); + } + + private static void WriteRoundRect(StringBuilder output, IoFatReportRectCommand command) + { + var y = command.TopY - command.Height; + var radius = Math.Min(command.Radius, Math.Min(command.Width, command.Height) / 2d); + var curve = radius * 0.55228475d; + if (command.StrokeThickness > 0d) + output.Append(Number(command.StrokeThickness)).Append(" w "); + output.Append(Fill(command.Fill)).Append(' '); + if (command.StrokeThickness > 0d) + output.Append(Stroke(command.Stroke)).Append(' '); + + output.Append(Number(command.X + radius)).Append(' ').Append(Number(y)).Append(" m ") + .Append(Number(command.X + command.Width - radius)).Append(' ').Append(Number(y)).Append(" l ") + .Append(Number(command.X + command.Width - radius + curve)).Append(' ').Append(Number(y)).Append(' ') + .Append(Number(command.X + command.Width)).Append(' ').Append(Number(y + radius - curve)).Append(' ') + .Append(Number(command.X + command.Width)).Append(' ').Append(Number(y + radius)).Append(" c ") + .Append(Number(command.X + command.Width)).Append(' ').Append(Number(y + command.Height - radius)).Append(" l ") + .Append(Number(command.X + command.Width)).Append(' ').Append(Number(y + command.Height - radius + curve)).Append(' ') + .Append(Number(command.X + command.Width - radius + curve)).Append(' ').Append(Number(y + command.Height)).Append(' ') + .Append(Number(command.X + command.Width - radius)).Append(' ').Append(Number(y + command.Height)).Append(" c ") + .Append(Number(command.X + radius)).Append(' ').Append(Number(y + command.Height)).Append(" l ") + .Append(Number(command.X + radius - curve)).Append(' ').Append(Number(y + command.Height)).Append(' ') + .Append(Number(command.X)).Append(' ').Append(Number(y + command.Height - radius + curve)).Append(' ') + .Append(Number(command.X)).Append(' ').Append(Number(y + command.Height - radius)).Append(" c ") + .Append(Number(command.X)).Append(' ').Append(Number(y + radius)).Append(" l ") + .Append(Number(command.X)).Append(' ').Append(Number(y + radius - curve)).Append(' ') + .Append(Number(command.X + radius - curve)).Append(' ').Append(Number(y)).Append(' ') + .Append(Number(command.X + radius)).Append(' ').Append(Number(y)) + .Append(command.StrokeThickness > 0d ? " c B\n" : " c f\n"); + } + + private static string Fill(IoFatReportColor color) + => $"{Channel(color.R)} {Channel(color.G)} {Channel(color.B)} rg"; + + private static string Stroke(IoFatReportColor color) + => $"{Channel(color.R)} {Channel(color.G)} {Channel(color.B)} RG"; + + private static string Channel(byte value) + => (value / 255d).ToString("0.###", CultureInfo.InvariantCulture); + + private static string ResourceName(IoFatReportFontKind font) => font switch + { + IoFatReportFontKind.Bold => "F2", + IoFatReportFontKind.Mono => "F3", + _ => "F1" + }; + + private static string EscapeLiteral(string value) + => value.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("(", "\\(", StringComparison.Ordinal) + .Replace(")", "\\)", StringComparison.Ordinal); + + private static string PdfDate(DateTimeOffset value) + => "D:" + value.ToLocalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); + + private static string Number(double value) + => value.ToString("0.###", CultureInfo.InvariantCulture); + + private static void WriteAscii(Stream stream, string text) + { + var bytes = Encoding.ASCII.GetBytes(text); + stream.Write(bytes, 0, bytes.Length); + } +} diff --git a/Services/IoTesting/IoFatPdfReportService.cs b/Services/IoTesting/IoFatPdfReportService.cs index 8e7dcc2..a251b2f 100644 --- a/Services/IoTesting/IoFatPdfReportService.cs +++ b/Services/IoTesting/IoFatPdfReportService.cs @@ -1,55 +1,30 @@ // Copyright 2026 Ari Sulistiono // SPDX-License-Identifier: Apache-2.0 -// -// Native PDF primitives adapted from the project-owned ARIEC60870 PDF engine. -// The IO FAT layout is purpose-built for ARSAS IEC 61850 evidence reports. -using System.Globalization; -using System.Text; using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester.Services.IoTesting; /// -/// Dependency-free native PDF 1.4 writer for ARSAS IO List FAT evidence. -/// -/// The implementation deliberately supports only the primitives needed by this -/// report: built-in Type 1 fonts, vector rectangles and lines, wrapped text, -/// paged tables, a cross-reference table, and document metadata. It does not -/// use a browser, HTML conversion, printer driver, or third-party PDF package. +/// Public native PDF contract for ARSAS IO List FAT evidence. +/// PDF output and the WPF print preview consume one shared report layout plan. /// public static class IoFatPdfReportService { - private const float PageWidth = 842f; // A4 landscape in PDF points. - private const float PageHeight = 595f; - private const float Margin = 30f; - private const float HeaderBottom = 500f; - private const float ContentTop = 484f; - private const float ContentBottom = 55f; - private const float ContentWidth = PageWidth - (Margin * 2f); - - private static readonly PdfColor BrandNavy = PdfColor.FromHex("0F172A"); - private static readonly PdfColor BrandBlue = PdfColor.FromHex("2563EB"); - private static readonly PdfColor SoftBlue = PdfColor.FromHex("EFF6FF"); - private static readonly PdfColor SoftSlate = PdfColor.FromHex("F8FAFC"); - private static readonly PdfColor Border = PdfColor.FromHex("DDE7F3"); - private static readonly PdfColor SoftLine = PdfColor.FromHex("EEF2F7"); - private static readonly PdfColor Muted = PdfColor.FromHex("64748B"); - private static readonly PdfColor Ink = PdfColor.FromHex("111827"); - private static readonly PdfColor White = PdfColor.FromHex("FFFFFF"); - private static readonly PdfColor Pass = PdfColor.FromHex("15803D"); - private static readonly PdfColor Attention = PdfColor.FromHex("B45309"); - private static readonly PdfColor Fail = PdfColor.FromHex("B91C1C"); - private static readonly PdfColor SoftPass = PdfColor.FromHex("F0FDF4"); - private static readonly PdfColor SoftAttention = PdfColor.FromHex("FFFBEB"); - private static readonly PdfColor SoftFail = PdfColor.FromHex("FEF2F2"); - public static byte[] Generate(IoTestProject project, DateTimeOffset? generatedAt = null) { ArgumentNullException.ThrowIfNull(project); - var created = generatedAt ?? DateTimeOffset.Now; - var pages = new Renderer(project, created).Render(); - return NativePdfDocument.Build(pages, project, created); + var layout = BuildLayout(project, generatedAt, draft: false); + return IoFatNativePdfWriter.Build(layout, project); + } + + internal static IoFatReportLayoutPlan BuildLayout( + IoTestProject project, + DateTimeOffset? generatedAt = null, + bool draft = false) + { + ArgumentNullException.ThrowIfNull(project); + return IoFatReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); } public static void Save(string fileName, IoTestProject project, DateTimeOffset? generatedAt = null) @@ -80,666 +55,4 @@ public static void Save(string fileName, IoTestProject project, DateTimeOffset? File.Delete(temporary); } } - - private sealed class Renderer - { - private readonly IoTestProject _project; - private readonly DateTimeOffset _created; - private readonly List _pages = new(); - private PdfPageBuffer _page = null!; - private float _cursorY; - - public Renderer(IoTestProject project, DateTimeOffset created) - { - _project = project; - _created = created; - } - - public IReadOnlyList Render() - { - NewPage(); - DrawExecutiveSummary(); - - foreach (var ied in _project.Ieds) - DrawIedSection(ied); - - if (_project.Ieds.Count == 0) - DrawEmptyProjectNotice(); - - var totalPages = _pages.Count; - for (var index = 0; index < totalPages; index++) - DrawPageChrome(_pages[index], index + 1, totalPages); - - return _pages; - } - - private void NewPage() - { - _page = new PdfPageBuffer(PageWidth, PageHeight); - _pages.Add(_page); - _cursorY = ContentTop; - } - - private void Ensure(float requiredHeight) - { - if (_cursorY - requiredHeight < ContentBottom) - NewPage(); - } - - private void DrawPageChrome(PdfPageBuffer page, int pageNumber, int totalPages) - { - var counts = Counts(_project); - var tone = ResolveOverallTone(counts); - var toneColor = ResolveToneColor(tone); - var toneBackground = ResolveToneBackground(tone); - - page.Line(Margin, HeaderBottom, PageWidth - Margin, HeaderBottom, Border, 0.8f); - page.Text(Margin, 562f, "ARSAS IO LIST TESTING", PdfFont.Bold, 7.2f, Muted); - page.Text(Margin, 541f, "IEC 61850 FAT Evidence Report", PdfFont.Bold, 20.2f, BrandNavy); - page.Text( - Margin, - 523f, - "Ordered OFF > ON > OFF transition evidence with IED and ARSAS timestamps.", - PdfFont.Regular, - 7.3f, - Muted); - - const float cardWidth = 142f; - const float cardHeight = 58f; - var cardX = PageWidth - Margin - cardWidth; - const float cardTop = 566f; - page.RoundRect(cardX, cardTop, cardWidth, cardHeight, 5f, toneBackground, toneColor, 0.8f); - page.Text(cardX + 10f, cardTop - 15f, "PROJECT STATUS", PdfFont.Bold, 6.2f, Muted); - page.Text(cardX + 10f, cardTop - 35f, tone, PdfFont.Bold, 16.4f, toneColor); - page.Text(cardX + 10f, cardTop - 49f, Truncate(_project.ProjectId, 28), PdfFont.Regular, 5.8f, Muted); - - page.Line(Margin, 42f, PageWidth - Margin, 42f, Border, 0.6f); - page.Text( - Margin, - 24f, - $"Generated {_created:yyyy-MM-dd HH:mm:ss zzz} | Project {_project.ProjectId} | Workbook SHA256 {ShortHash(_project.SourceWorkbookSha256)}", - PdfFont.Regular, - 6.2f, - Muted); - page.Text(PageWidth - Margin - 72f, 24f, $"Page {pageNumber} / {totalPages}", PdfFont.Regular, 6.2f, Muted); - } - - private void DrawExecutiveSummary() - { - var counts = Counts(_project); - const float height = 104f; - Ensure(height + 12f); - - _page.RoundRect(Margin, _cursorY, ContentWidth, height, 5f, SoftSlate, Border, 0.8f); - _page.Text(Margin + 13f, _cursorY - 19f, "Project Evidence Summary", PdfFont.Bold, 11.2f, BrandNavy); - _page.Text(Margin + 13f, _cursorY - 36f, $"{Clean(_project.ProjectName)} | {Clean(_project.ProjectId)}", PdfFont.Bold, 8.2f, Ink); - _page.Text(Margin + 13f, _cursorY - 51f, $"Source: {Clean(_project.SourceWorkbookName)}", PdfFont.Regular, 6.8f, Muted); - _page.Text(Margin + 13f, _cursorY - 64f, $"Workbook SHA-256: {Clean(_project.SourceWorkbookSha256)}", PdfFont.Mono, 5.8f, Muted); - - const float metricTop = 86f; - const float gap = 7f; - var metricWidth = (ContentWidth - 26f - (gap * 5f)) / 6f; - var x = Margin + 13f; - DrawMetric(x, _cursorY - metricTop, metricWidth, "IED", _project.Ieds.Count.ToString(CultureInfo.InvariantCulture), BrandBlue, SoftBlue); - x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "SIGNALS", _project.SignalCount.ToString(CultureInfo.InvariantCulture), BrandNavy, White); - x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "PASS", counts.Passed.ToString(CultureInfo.InvariantCulture), Pass, SoftPass); - x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "REVIEW", counts.Review.ToString(CultureInfo.InvariantCulture), Attention, SoftAttention); - x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "FAIL", counts.Failed.ToString(CultureInfo.InvariantCulture), Fail, SoftFail); - x += metricWidth + gap; - DrawMetric(x, _cursorY - metricTop, metricWidth, "PENDING", counts.Pending.ToString(CultureInfo.InvariantCulture), Muted, White); - - _cursorY -= height + 12f; - } - - private void DrawMetric(float x, float top, float width, string label, string value, PdfColor color, PdfColor background) - { - _page.RoundRect(x, top, width, 29f, 4f, background, Border, 0.55f); - _page.Text(x + 7f, top - 11f, label, PdfFont.Bold, 5.3f, Muted); - _page.Text(x + 7f, top - 23f, value, PdfFont.Bold, 9.2f, color); - } - - private void DrawIedSection(IoTestIedPlan ied) - { - Ensure(82f); - DrawIedHeader(ied, continued: false); - DrawTableHeader(); - - var rowNumber = 0; - foreach (var point in ied.TestPoints) - { - rowNumber++; - var cells = BuildCells(point, rowNumber); - var rowHeight = EstimateRowHeight(cells); - if (_cursorY - rowHeight < ContentBottom) - { - NewPage(); - DrawIedHeader(ied, continued: true); - DrawTableHeader(); - } - DrawRow(cells, rowHeight); - } - - if (ied.TestPoints.Count == 0) - { - Ensure(34f); - _page.RoundRect(Margin, _cursorY, ContentWidth, 28f, 4f, SoftSlate, Border, 0.6f); - _page.Text(Margin + 10f, _cursorY - 18f, "No IO-list signals are available for this IED.", PdfFont.Regular, 7f, Muted); - _cursorY -= 38f; - } - - _cursorY -= 11f; - } - - private void DrawIedHeader(IoTestIedPlan ied, bool continued) - { - var title = continued ? $"{ied.IedName} (continued)" : ied.IedName; - var pending = Math.Max(0, ied.TestPoints.Count - ied.PassedCount - ied.ReviewCount - ied.TestPoints.Count(point => point.Runtime.State == IoTestPointState.Failed)); - const float height = 48f; - _page.RoundRect(Margin, _cursorY, ContentWidth, height, 5f, White, Border, 0.7f); - _page.Rect(Margin, _cursorY, 4f, height, BrandBlue, BrandBlue, 0f); - _page.Text(Margin + 13f, _cursorY - 17f, Clean(title), PdfFont.Bold, 10.2f, BrandNavy); - _page.Text( - Margin + 13f, - _cursorY - 33f, - $"{Clean(ied.IpAddress)} | {Clean(ied.IedRole)} | {Clean(ied.Location)} | {Clean(ied.VoltageLevel)} | {Clean(ied.Switchgear)}", - PdfFont.Regular, - 6.3f, - Muted); - _page.Text( - PageWidth - Margin - 218f, - _cursorY - 18f, - $"{ied.TestPoints.Count} signals | {ied.PassedCount} PASS | {ied.ReviewCount} review | {pending} pending", - PdfFont.Bold, - 6.1f, - BrandBlue); - _cursorY -= height + 7f; - } - - private void DrawTableHeader() - { - Ensure(22f); - var widths = ColumnWidths(); - var headers = new[] - { - "#", "Signal", "IEC 61850 reference", "Expected ON / OFF", "ON evidence", "OFF evidence", "Result", "Reason" - }; - var x = Margin; - const float height = 19f; - for (var index = 0; index < headers.Length; index++) - { - _page.Rect(x, _cursorY, widths[index], height, SoftBlue, Border, 0.45f); - _page.Text(x + 4f, _cursorY - 12.5f, headers[index], PdfFont.Bold, 5.55f, BrandBlue); - x += widths[index]; - } - _cursorY -= height; - } - - private void DrawRow(IReadOnlyList cells, float rowHeight) - { - var widths = ColumnWidths(); - var x = Margin; - for (var index = 0; index < cells.Count; index++) - { - var cell = cells[index]; - _page.Rect(x, _cursorY, widths[index], rowHeight, White, SoftLine, 0.35f); - var lines = WrapText(cell.Text, widths[index] - 8f, cell.FontSize, cell.MaxLines); - var y = _cursorY - 8.5f; - foreach (var line in lines) - { - _page.Text(x + 4f, y, line, cell.Font, cell.FontSize, cell.Color); - y -= cell.FontSize + 1.35f; - } - x += widths[index]; - } - _cursorY -= rowHeight; - } - - private static ReportCell[] BuildCells(IoTestPointPlan point, int rowNumber) - { - var stateColor = point.Runtime.State switch - { - IoTestPointState.Passed => Pass, - IoTestPointState.Failed => Fail, - IoTestPointState.Review => Attention, - _ => Muted - }; - - return new[] - { - new ReportCell(rowNumber.ToString(CultureInfo.InvariantCulture), PdfFont.Mono, 5.35f, Ink, 1), - new ReportCell(point.SignalName, PdfFont.Bold, 5.65f, Ink, 3), - new ReportCell(point.ObjectReference, PdfFont.Mono, 5.15f, Ink, 3), - new ReportCell($"ON {point.ExpectedOnText} ({point.ExpectedOnRaw})\nOFF {point.ExpectedOffText} ({point.ExpectedOffRaw})", PdfFont.Regular, 5.35f, Ink, 3), - new ReportCell(EvidenceText(point.Runtime.OnEvidence), PdfFont.Regular, 5.05f, Ink, 4), - new ReportCell(EvidenceText(point.Runtime.OffEvidence), PdfFont.Regular, 5.05f, Ink, 4), - new ReportCell(point.Runtime.State.ToString().ToUpperInvariant(), PdfFont.Bold, 5.45f, stateColor, 2), - new ReportCell(point.Runtime.StatusReason, PdfFont.Regular, 5.2f, Ink, 3) - }; - } - - private static float EstimateRowHeight(IReadOnlyList cells) - { - var widths = ColumnWidths(); - var maximum = 1; - for (var index = 0; index < cells.Count; index++) - { - var lineCount = WrapText(cells[index].Text, widths[index] - 8f, cells[index].FontSize, cells[index].MaxLines).Count; - maximum = Math.Max(maximum, lineCount); - } - return Math.Max(18f, 8f + (maximum * 7.15f)); - } - - private static float[] ColumnWidths() - => new[] { 24f, 108f, 165f, 78f, 119f, 119f, 52f, 117f }; - - private void DrawEmptyProjectNotice() - { - Ensure(48f); - _page.RoundRect(Margin, _cursorY, ContentWidth, 42f, 5f, SoftAttention, Border, 0.7f); - _page.Text(Margin + 12f, _cursorY - 25f, "No IED test plan is present in this project.", PdfFont.Bold, 9f, Attention); - _cursorY -= 52f; - } - } - - private sealed record ReportCell(string Text, PdfFont Font, float FontSize, PdfColor Color, int MaxLines); - - private sealed class PdfPageBuffer - { - private readonly StringBuilder _operations = new(); - - public PdfPageBuffer(float width, float height) - { - Width = width; - Height = height; - } - - public float Width { get; } - public float Height { get; } - public string Content => _operations.ToString(); - - public void Text(float x, float baselineY, string text, PdfFont font, float size, PdfColor color) - { - var safe = SanitizePdfText(text); - if (safe.Length == 0) - return; - - _operations.Append("BT ") - .Append(color.FillOperation()).Append(' ') - .Append('/').Append(font.ResourceName()).Append(' ').Append(Number(size)).Append(" Tf ") - .Append("1 0 0 1 ").Append(Number(x)).Append(' ').Append(Number(baselineY)).Append(" Tm ") - .Append('(').Append(EscapeLiteral(safe)).Append(") Tj ET\n"); - } - - public void Line(float x1, float y1, float x2, float y2, PdfColor stroke, float width) - { - _operations.Append(Number(width)).Append(" w ") - .Append(stroke.StrokeOperation()).Append(' ') - .Append(Number(x1)).Append(' ').Append(Number(y1)).Append(" m ") - .Append(Number(x2)).Append(' ').Append(Number(y2)).Append(" l S\n"); - } - - public void Rect(float x, float top, float width, float height, PdfColor fill, PdfColor stroke, float lineWidth) - { - var y = top - height; - if (lineWidth <= 0f || fill.Equals(stroke)) - { - _operations.Append(fill.FillOperation()).Append(' ') - .Append(Number(x)).Append(' ').Append(Number(y)).Append(' ') - .Append(Number(width)).Append(' ').Append(Number(height)).Append(" re f\n"); - return; - } - - _operations.Append(Number(lineWidth)).Append(" w ") - .Append(fill.FillOperation()).Append(' ') - .Append(stroke.StrokeOperation()).Append(' ') - .Append(Number(x)).Append(' ').Append(Number(y)).Append(' ') - .Append(Number(width)).Append(' ').Append(Number(height)).Append(" re B\n"); - } - - public void RoundRect(float x, float top, float width, float height, float radius, PdfColor fill, PdfColor stroke, float lineWidth) - { - if (radius <= 0f) - { - Rect(x, top, width, height, fill, stroke, lineWidth); - return; - } - - var y = top - height; - var r = Math.Min(radius, Math.Min(width, height) / 2f); - var c = r * 0.55228475f; - - if (lineWidth > 0f) - _operations.Append(Number(lineWidth)).Append(" w "); - _operations.Append(fill.FillOperation()).Append(' '); - if (lineWidth > 0f) - _operations.Append(stroke.StrokeOperation()).Append(' '); - - _operations.Append(Number(x + r)).Append(' ').Append(Number(y)).Append(" m ") - .Append(Number(x + width - r)).Append(' ').Append(Number(y)).Append(" l ") - .Append(Number(x + width - r + c)).Append(' ').Append(Number(y)).Append(' ') - .Append(Number(x + width)).Append(' ').Append(Number(y + r - c)).Append(' ') - .Append(Number(x + width)).Append(' ').Append(Number(y + r)).Append(" c ") - .Append(Number(x + width)).Append(' ').Append(Number(y + height - r)).Append(" l ") - .Append(Number(x + width)).Append(' ').Append(Number(y + height - r + c)).Append(' ') - .Append(Number(x + width - r + c)).Append(' ').Append(Number(y + height)).Append(' ') - .Append(Number(x + width - r)).Append(' ').Append(Number(y + height)).Append(" c ") - .Append(Number(x + r)).Append(' ').Append(Number(y + height)).Append(" l ") - .Append(Number(x + r - c)).Append(' ').Append(Number(y + height)).Append(' ') - .Append(Number(x)).Append(' ').Append(Number(y + height - r + c)).Append(' ') - .Append(Number(x)).Append(' ').Append(Number(y + height - r)).Append(" c ") - .Append(Number(x)).Append(' ').Append(Number(y + r)).Append(" l ") - .Append(Number(x)).Append(' ').Append(Number(y + r - c)).Append(' ') - .Append(Number(x + r - c)).Append(' ').Append(Number(y)).Append(' ') - .Append(Number(x + r)).Append(' ').Append(Number(y)) - .Append(lineWidth > 0f ? " c B\n" : " c f\n"); - } - } - - private static class NativePdfDocument - { - public static byte[] Build( - IReadOnlyList pages, - IoTestProject project, - DateTimeOffset created) - { - if (pages.Count == 0) - throw new InvalidOperationException("At least one PDF page is required."); - - var objects = new List(); - int AddObject(string body) - { - objects.Add(Encoding.ASCII.GetBytes(body)); - return objects.Count; - } - - var catalogId = AddObject("<< /Type /Catalog /Pages 2 0 R >>"); - var pagesId = AddObject("__PAGES__"); - var fontRegularId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); - var fontBoldId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"); - var fontMonoId = AddObject("<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>"); - var pageIds = new List(); - - foreach (var page in pages) - { - var contentBytes = Encoding.ASCII.GetBytes(page.Content); - var contentId = AddObject( - $"<< /Length {contentBytes.Length.ToString(CultureInfo.InvariantCulture)} >>\nstream\n{page.Content}endstream"); - var pageId = AddObject( - $"<< /Type /Page /Parent {pagesId} 0 R /MediaBox [0 0 {Number(page.Width)} {Number(page.Height)}] " + - $"/Resources << /Font << /F1 {fontRegularId} 0 R /F2 {fontBoldId} 0 R /F3 {fontMonoId} 0 R >> >> " + - $"/Contents {contentId} 0 R >>"); - pageIds.Add(pageId); - } - - var title = $"{project.ProjectName} - ARSAS IO FAT Evidence Report"; - var infoId = AddObject( - $"<< /Title ({EscapeLiteral(SanitizePdfText(title))}) " + - "/Author (ARSAS) " + - "/Creator (ARSAS Native PDF Engine, ported from ARIEC60870) " + - "/Producer (ARSAS Native PDF Engine) " + - $"/CreationDate ({PdfDate(created)}) >>"); - - objects[pagesId - 1] = Encoding.ASCII.GetBytes( - $"<< /Type /Pages /Kids [{string.Join(" ", pageIds.Select(id => $"{id} 0 R"))}] /Count {pageIds.Count} >>"); - - using var stream = new MemoryStream(); - WriteAscii(stream, "%PDF-1.4\n%ARSAS native PDF\n"); - var offsets = new long[objects.Count + 1]; - for (var index = 0; index < objects.Count; index++) - { - offsets[index + 1] = stream.Position; - WriteAscii(stream, $"{index + 1} 0 obj\n"); - stream.Write(objects[index], 0, objects[index].Length); - WriteAscii(stream, "\nendobj\n"); - } - - var xrefOffset = stream.Position; - WriteAscii(stream, $"xref\n0 {objects.Count + 1}\n"); - WriteAscii(stream, "0000000000 65535 f \n"); - for (var index = 1; index < offsets.Length; index++) - WriteAscii(stream, offsets[index].ToString("0000000000", CultureInfo.InvariantCulture) + " 00000 n \n"); - - WriteAscii( - stream, - $"trailer\n<< /Size {objects.Count + 1} /Root {catalogId} 0 R /Info {infoId} 0 R >>\n" + - $"startxref\n{xrefOffset.ToString(CultureInfo.InvariantCulture)}\n%%EOF\n"); - return stream.ToArray(); - } - - private static void WriteAscii(Stream stream, string text) - { - var bytes = Encoding.ASCII.GetBytes(text); - stream.Write(bytes, 0, bytes.Length); - } - } - - private readonly record struct ProjectCounts(int Passed, int Review, int Failed, int Pending); - - private static ProjectCounts Counts(IoTestProject project) - { - var points = project.Ieds.SelectMany(ied => ied.TestPoints).ToList(); - var passed = points.Count(point => point.Runtime.State == IoTestPointState.Passed); - var review = points.Count(point => point.Runtime.State == IoTestPointState.Review); - var failed = points.Count(point => point.Runtime.State == IoTestPointState.Failed); - return new ProjectCounts(passed, review, failed, Math.Max(0, points.Count - passed - review - failed)); - } - - private static string ResolveOverallTone(ProjectCounts counts) - { - if (counts.Failed > 0) - return "FAILED"; - if (counts.Review > 0) - return "REVIEW"; - if (counts.Pending > 0) - return "IN PROGRESS"; - return counts.Passed > 0 ? "PASSED" : "NOT STARTED"; - } - - private static PdfColor ResolveToneColor(string tone) => tone switch - { - "PASSED" => Pass, - "FAILED" => Fail, - "REVIEW" => Attention, - _ => BrandBlue - }; - - private static PdfColor ResolveToneBackground(string tone) => tone switch - { - "PASSED" => SoftPass, - "FAILED" => SoftFail, - "REVIEW" => SoftAttention, - _ => SoftBlue - }; - - private static string EvidenceText(IoTestTransitionEvidence? evidence) - { - if (evidence == null) - return "-"; - var ied = evidence.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture) ?? "not supplied"; - return $"IED {ied}\nARSAS {evidence.CapturedAt:yyyy-MM-dd HH:mm:ss.fff zzz}\n{evidence.RawValue} | {evidence.Quality} | {evidence.AcquisitionSource}"; - } - - private static IReadOnlyList WrapText(string? value, float width, float fontSize, int maxLines) - { - var input = (value ?? string.Empty).Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); - if (string.IsNullOrWhiteSpace(input)) - return new[] { "-" }; - - var charsPerLine = Math.Max(7, (int)Math.Floor(width / Math.Max(2.4f, fontSize * 0.49f))); - var lines = new List(); - var truncated = false; - - foreach (var paragraphValue in input.Split('\n')) - { - var paragraph = SanitizePdfText(paragraphValue); - if (paragraph.Length == 0) - paragraph = "-"; - var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var current = new StringBuilder(); - - foreach (var originalWord in words) - { - var word = originalWord; - while (word.Length > charsPerLine) - { - if (current.Length > 0) - { - lines.Add(current.ToString()); - current.Clear(); - if (lines.Count >= maxLines) - { - truncated = true; - break; - } - } - lines.Add(word[..charsPerLine]); - word = word[charsPerLine..]; - if (lines.Count >= maxLines) - { - truncated = word.Length > 0; - break; - } - } - if (lines.Count >= maxLines) - break; - - if (current.Length == 0) - current.Append(word); - else if (current.Length + 1 + word.Length <= charsPerLine) - current.Append(' ').Append(word); - else - { - lines.Add(current.ToString()); - current.Clear().Append(word); - if (lines.Count >= maxLines) - { - truncated = true; - break; - } - } - } - - if (lines.Count >= maxLines) - break; - if (current.Length > 0) - lines.Add(current.ToString()); - if (lines.Count >= maxLines) - { - truncated = true; - break; - } - } - - if (lines.Count == 0) - lines.Add("-"); - if (lines.Count > maxLines) - lines = lines.Take(maxLines).ToList(); - if (truncated && lines[^1].Length > 3) - lines[^1] = lines[^1][..Math.Max(0, lines[^1].Length - 3)] + "..."; - return lines; - } - - private static string Clean(string? value) - { - var normalized = (value ?? string.Empty) - .Replace("\r", " ", StringComparison.Ordinal) - .Replace("\n", " ", StringComparison.Ordinal) - .Trim(); - return string.IsNullOrWhiteSpace(normalized) ? "-" : normalized; - } - - private static string ShortHash(string? value) - { - var clean = Clean(value); - return clean.Length <= 16 ? clean : clean[..16]; - } - - private static string Truncate(string? value, int maximum) - { - var clean = Clean(value); - if (clean.Length <= maximum || maximum <= 3) - return clean; - return clean[..(maximum - 3)] + "..."; - } - - private static string SanitizePdfText(string? value) - { - var input = Clean(value); - var builder = new StringBuilder(input.Length); - foreach (var character in input) - { - builder.Append(character switch - { - '\u2013' or '\u2014' or '\u2212' => '-', - '\u2192' => '>', - '\u2190' => '<', - '\u00B7' => '|', - '\u00A0' => ' ', - >= ' ' and <= '~' => character, - _ => ' ' - }); - } - return builder.ToString().Trim(); - } - - private static string EscapeLiteral(string value) - => value.Replace("\\", "\\\\", StringComparison.Ordinal) - .Replace("(", "\\(", StringComparison.Ordinal) - .Replace(")", "\\)", StringComparison.Ordinal); - - private static string PdfDate(DateTimeOffset value) - => "D:" + value.ToLocalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); - - private static string Number(float value) - => value.ToString("0.###", CultureInfo.InvariantCulture); - - private readonly struct PdfColor : IEquatable - { - public PdfColor(float red, float green, float blue) - { - Red = red; - Green = green; - Blue = blue; - } - - public float Red { get; } - public float Green { get; } - public float Blue { get; } - - public static PdfColor FromHex(string hex) - { - var value = hex.StartsWith("#", StringComparison.Ordinal) ? hex[1..] : hex; - if (value.Length != 6) - throw new ArgumentException("PDF color must be a six-digit RGB hex value.", nameof(hex)); - return new PdfColor( - Convert.ToInt32(value[..2], 16) / 255f, - Convert.ToInt32(value.Substring(2, 2), 16) / 255f, - Convert.ToInt32(value.Substring(4, 2), 16) / 255f); - } - - public string FillOperation() => $"{Number(Red)} {Number(Green)} {Number(Blue)} rg"; - public string StrokeOperation() => $"{Number(Red)} {Number(Green)} {Number(Blue)} RG"; - public bool Equals(PdfColor other) - => Math.Abs(Red - other.Red) < 0.0001f && Math.Abs(Green - other.Green) < 0.0001f && Math.Abs(Blue - other.Blue) < 0.0001f; - public override bool Equals(object? obj) => obj is PdfColor other && Equals(other); - public override int GetHashCode() => HashCode.Combine(Red, Green, Blue); - } - - private enum PdfFont - { - Regular, - Bold, - Mono - } - - private static string ResourceName(this PdfFont font) => font switch - { - PdfFont.Bold => "F2", - PdfFont.Mono => "F3", - _ => "F1" - }; } diff --git a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs new file mode 100644 index 0000000..cf91ab8 --- /dev/null +++ b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs @@ -0,0 +1,158 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Markup; +using System.Windows.Media; +using System.Windows.Shapes; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Native WPF renderer for the shared IO FAT report layout plan. This is adapted +/// from the project-owned ARIEC60870 FixedDocument preview architecture. +/// +internal static class IoFatReportPreviewDocumentBuilder +{ + private const double DipPerPdfPoint = 96d / 72d; + private static readonly FontFamily ReportFont = new("Arial, Segoe UI"); + private static readonly FontFamily MonoFont = new("Consolas, Cascadia Mono"); + + public static FixedDocument Build( + IoTestProject project, + bool draft, + DateTimeOffset? generatedAt = null) + { + ArgumentNullException.ThrowIfNull(project); + var layout = IoFatReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); + return Render(layout); + } + + public static FixedDocument Render(IoFatReportLayoutPlan layout) + { + ArgumentNullException.ThrowIfNull(layout); + var document = new FixedDocument(); + foreach (var pagePlan in layout.Pages) + { + var fixedPage = new FixedPage + { + Width = pagePlan.Width * DipPerPdfPoint, + Height = pagePlan.Height * DipPerPdfPoint, + Background = Brushes.White, + SnapsToDevicePixels = true + }; + fixedPage.SetValue(TextOptions.TextFormattingModeProperty, TextFormattingMode.Ideal); + fixedPage.SetValue(TextOptions.TextRenderingModeProperty, TextRenderingMode.ClearType); + + foreach (var command in pagePlan.Commands) + { + switch (command) + { + case IoFatReportRectCommand rect: + AddRectangle(fixedPage, pagePlan.Height, rect); + break; + case IoFatReportLineCommand line: + AddLine(fixedPage, pagePlan.Height, line); + break; + case IoFatReportTextCommand text: + AddText(fixedPage, pagePlan.Height, text); + break; + } + } + + var content = new PageContent(); + ((IAddChild)content).AddChild(fixedPage); + document.Pages.Add(content); + } + return document; + } + + private static void AddRectangle(FixedPage page, double pageHeight, IoFatReportRectCommand command) + { + var border = new Border + { + Background = ToBrush(command.Fill), + BorderBrush = ToBrush(command.Stroke), + BorderThickness = command.StrokeThickness <= 0d + ? new Thickness(0) + : new Thickness(Math.Max(0.5d, command.StrokeThickness * DipPerPdfPoint)), + CornerRadius = new CornerRadius(Math.Max(0d, command.Radius * DipPerPdfPoint)), + SnapsToDevicePixels = true + }; + Add( + page, + border, + command.X * DipPerPdfPoint, + (pageHeight - command.TopY) * DipPerPdfPoint, + command.Width * DipPerPdfPoint, + command.Height * DipPerPdfPoint); + } + + private static void AddLine(FixedPage page, double pageHeight, IoFatReportLineCommand command) + { + var x1 = command.X1 * DipPerPdfPoint; + var y1 = (pageHeight - command.Y1) * DipPerPdfPoint; + var x2 = command.X2 * DipPerPdfPoint; + var y2 = (pageHeight - command.Y2) * DipPerPdfPoint; + var left = Math.Min(x1, x2); + var top = Math.Min(y1, y2); + var line = new Line + { + X1 = x1 - left, + Y1 = y1 - top, + X2 = x2 - left, + Y2 = y2 - top, + Stroke = ToBrush(command.Stroke), + StrokeThickness = Math.Max(0.5d, command.StrokeThickness * DipPerPdfPoint), + SnapsToDevicePixels = true + }; + Add(page, line, left, top, Math.Max(1d, Math.Abs(x2 - x1) + 1d), Math.Max(1d, Math.Abs(y2 - y1) + 1d)); + } + + private static void AddText(FixedPage page, double pageHeight, IoFatReportTextCommand command) + { + var fontSize = Math.Max(1d, command.FontSize * DipPerPdfPoint); + var top = (pageHeight - command.BaselineY - (command.FontSize * 0.82d)) * DipPerPdfPoint; + var block = new TextBlock + { + Text = command.Text, + FontFamily = command.Font == IoFatReportFontKind.Mono ? MonoFont : ReportFont, + FontSize = fontSize, + FontWeight = command.Font == IoFatReportFontKind.Bold ? FontWeights.Bold : FontWeights.Normal, + Foreground = ToBrush(command.Color), + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + LineStackingStrategy = LineStackingStrategy.BlockLineHeight, + LineHeight = Math.Max(fontSize + 1.5d, fontSize * 1.18d), + SnapsToDevicePixels = true + }; + block.SetValue(TextOptions.TextFormattingModeProperty, TextFormattingMode.Ideal); + block.SetValue(TextOptions.TextRenderingModeProperty, TextRenderingMode.ClearType); + Add( + page, + block, + command.X * DipPerPdfPoint, + top, + Math.Max(4d, command.Width * DipPerPdfPoint), + Math.Max(fontSize + 3d, fontSize * 1.25d)); + } + + private static void Add(FixedPage page, UIElement element, double x, double y, double width, double height) + { + element.SetValue(FrameworkElement.WidthProperty, width); + element.SetValue(FrameworkElement.HeightProperty, height); + FixedPage.SetLeft(element, x); + FixedPage.SetTop(element, y); + page.Children.Add(element); + } + + private static Brush ToBrush(IoFatReportColor color) + { + var brush = new SolidColorBrush(Color.FromRgb(color.R, color.G, color.B)); + brush.Freeze(); + return brush; + } +} From e0a785add068d6de8369dcc17650d6a48bae30cd Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 17:32:37 +0700 Subject: [PATCH 3/5] Replace HTML preview with native paged report viewer and polish FAT grid --- IoListTestingWindow.GridConverters.cs | 87 ++++ IoListTestingWindow.PrintPreview.cs | 402 +++++++++++++----- .../IoTesting/IoFatReportPreviewService.cs | 102 +---- 3 files changed, 381 insertions(+), 210 deletions(-) create mode 100644 IoListTestingWindow.GridConverters.cs diff --git a/IoListTestingWindow.GridConverters.cs b/IoListTestingWindow.GridConverters.cs new file mode 100644 index 0000000..2a226bd --- /dev/null +++ b/IoListTestingWindow.GridConverters.cs @@ -0,0 +1,87 @@ +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +internal sealed class IoFatBooleanValueBrushConverter : IValueConverter +{ + private static readonly Brush TrueBrush = Brush(229, 72, 77); + private static readonly Brush FalseBrush = Brush(22, 166, 106); + private static readonly Brush NeutralBrush = Brush(17, 24, 39); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = value?.ToString()?.Trim() ?? string.Empty; + if (text.Equals("True", StringComparison.OrdinalIgnoreCase) || text == "1") return TrueBrush; + if (text.Equals("False", StringComparison.OrdinalIgnoreCase) || text == "0") return FalseBrush; + return NeutralBrush; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; + private static Brush Brush(byte r, byte g, byte b) { var value = new SolidColorBrush(Color.FromRgb(r, g, b)); value.Freeze(); return value; } +} + +internal sealed class IoFatLiveBrushConverter : IValueConverter +{ + private static readonly Brush LiveBrush = Brush(22, 132, 90); + private static readonly Brush MutedBrush = Brush(101, 117, 139); + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value is true ? LiveBrush : MutedBrush; + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; + private static Brush Brush(byte r, byte g, byte b) { var value = new SolidColorBrush(Color.FromRgb(r, g, b)); value.Freeze(); return value; } +} + +internal sealed class IoFatEvidenceBrushConverter : IValueConverter +{ + private static readonly Brush SuccessBrush = Brush(22, 132, 90); + private static readonly Brush MutedBrush = Brush(138, 151, 169); + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value == null ? MutedBrush : SuccessBrush; + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; + private static Brush Brush(byte r, byte g, byte b) { var result = new SolidColorBrush(Color.FromRgb(r, g, b)); result.Freeze(); return result; } +} + +internal sealed class IoFatQualityBrushConverter : IValueConverter +{ + private static readonly Brush GoodBrush = Brush(22, 132, 90); + private static readonly Brush BadBrush = Brush(197, 58, 69); + private static readonly Brush MutedBrush = Brush(96, 112, 137); + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = value?.ToString()?.Trim() ?? string.Empty; + if (text.Equals("good", StringComparison.OrdinalIgnoreCase)) return GoodBrush; + if (text.Contains("invalid", StringComparison.OrdinalIgnoreCase) || text.Contains("bad", StringComparison.OrdinalIgnoreCase)) return BadBrush; + return MutedBrush; + } + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; + private static Brush Brush(byte r, byte g, byte b) { var result = new SolidColorBrush(Color.FromRgb(r, g, b)); result.Freeze(); return result; } +} + +internal sealed class IoFatStateBrushConverter : IValueConverter +{ + private static readonly Brush PassBrush = Brush(22, 132, 90); + private static readonly Brush ReviewBrush = Brush(154, 101, 0); + private static readonly Brush FailBrush = Brush(197, 58, 69); + private static readonly Brush MutedBrush = Brush(96, 112, 137); + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value switch + { + IoTestPointState.Passed => PassBrush, + IoTestPointState.Review => ReviewBrush, + IoTestPointState.Failed => FailBrush, + _ => MutedBrush + }; + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; + private static Brush Brush(byte r, byte g, byte b) { var result = new SolidColorBrush(Color.FromRgb(r, g, b)); result.Freeze(); return result; } +} + +internal sealed class IoFatResultTextConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value switch + { + IoTestPointState.Passed => "✔ PASS", + IoTestPointState.Review => "⚠ REVIEW", + IoTestPointState.Failed => "✖ FAILED", + _ => "—" + }; + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; +} diff --git a/IoListTestingWindow.PrintPreview.cs b/IoListTestingWindow.PrintPreview.cs index 1db01c0..71cb50b 100644 --- a/IoListTestingWindow.PrintPreview.cs +++ b/IoListTestingWindow.PrintPreview.cs @@ -1,6 +1,10 @@ using System.ComponentModel; +using System.Globalization; using System.Windows; using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; using System.Windows.Media; using System.Windows.Threading; using ArIED61850Tester.Services.IoTesting; @@ -14,10 +18,10 @@ public partial class IoListTestingWindow private bool _printPreviewActive; private DataGrid? _signalWorkspaceGrid; private Grid? _printPreviewHost; - private WebBrowser? _printPreviewBrowser; + private DocumentViewer? _printPreviewDocumentViewer; private Button? _printPreviewToggle; - private TextBlock? _printPreviewTitle; - private TextBlock? _printPreviewSubtitle; + private TextBlock? _printPreviewZoomText; + private TextBlock? _printPreviewPageText; private DispatcherTimer? _preparationStateGuard; protected override void OnContentRendered(EventArgs e) @@ -39,12 +43,8 @@ private void InstallPerIedPrintPreview() if (Content is not Grid root) return; - var middle = root.Children - .OfType() - .FirstOrDefault(child => Grid.GetRow(child) == 2); - var workspaceBorder = middle?.Children - .OfType() - .FirstOrDefault(child => Grid.GetColumn(child) == 2); + var middle = root.Children.OfType().FirstOrDefault(child => Grid.GetRow(child) == 2); + var workspaceBorder = middle?.Children.OfType().FirstOrDefault(child => Grid.GetColumn(child) == 2); if (workspaceBorder?.Child is not Grid workspaceGrid) return; @@ -52,6 +52,7 @@ private void InstallPerIedPrintPreview() if (_signalWorkspaceGrid == null) return; + InstallSignalGridPolish(_signalWorkspaceGrid); RemoveMainPreparationSurface(workspaceGrid); InstallPreviewToggle(root); _printPreviewHost = BuildPrintPreviewHost(); @@ -63,11 +64,9 @@ private static void RemoveMainPreparationSurface(Grid workspaceGrid) { var preparationSurface = workspaceGrid.Children .OfType() - .FirstOrDefault(border => Grid.GetRow(border) == 1 && - border.Descendants().Any(progress => progress.IsIndeterminate)); + .FirstOrDefault(border => Grid.GetRow(border) == 1 && border.Descendants().Any(progress => progress.IsIndeterminate)); if (preparationSurface != null) workspaceGrid.Children.Remove(preparationSurface); - if (workspaceGrid.RowDefinitions.Count > 1) workspaceGrid.RowDefinitions[1].Height = new GridLength(0); if (workspaceGrid.RowDefinitions.Count > 2) @@ -76,21 +75,17 @@ private static void RemoveMainPreparationSurface(Grid workspaceGrid) private void InstallPreviewToggle(Grid root) { - var headerBorder = root.Children - .OfType() - .FirstOrDefault(child => Grid.GetRow(child) == 0); - var headerGrid = headerBorder?.Child as Grid; - var actions = headerGrid?.Children.OfType().FirstOrDefault(); + var headerBorder = root.Children.OfType().FirstOrDefault(child => Grid.GetRow(child) == 0); + var actions = (headerBorder?.Child as Grid)?.Children.OfType().FirstOrDefault(); if (actions == null) return; - _printPreviewToggle = BuildButton("Print Preview", TogglePrintPreview_Click, primary: false); - _printPreviewToggle.ToolTip = "Preview the selected IED report in the workspace"; - var pdfButtonIndex = actions.Children - .OfType