using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using SSPCTester.Logic.Models; using A = DocumentFormat.OpenXml.Drawing; using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; using PIC = DocumentFormat.OpenXml.Drawing.Pictures; namespace SSPCTester.Logic.Report; public sealed class WordReportGenerator { private const string FontName = "SimSun"; private const string Navy = "17324D"; private const string Blue = "245B82"; private const string PaleBlue = "EAF1F6"; private const string Line = "CBD5DF"; private const string TextColor = "1E2933"; private const string Muted = "667585"; private const string Success = "287A4B"; private const string Danger = "A53232"; private const string Warning = "98651A"; public Dictionary CurveImages { get; set; } = new(); public void Generate(Project project, string outputPath) { Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); using var document = WordprocessingDocument.Create(outputPath, WordprocessingDocumentType.Document); var main = document.AddMainDocumentPart(); main.Document = new Document(new Body()); AddStyles(main); Body body = main.Document.Body!; string reportNo = BuildReportNumber(project); if (project.Report.Cover) ComposeCover(body, project, reportNo); ComposeBody(body, main, project, reportNo); body.Append(PageSectionProperties()); main.Document.Save(); } private static void AddStyles(MainDocumentPart main) { var styles = main.AddNewPart(); styles.Styles = new Styles( new DocDefaults( new RunPropertiesDefault(new RunProperties( new RunFonts { Ascii = FontName, HighAnsi = FontName, EastAsia = FontName }, new FontSize { Val = "20" }, new Color { Val = TextColor })), new ParagraphPropertiesDefault(new ParagraphProperties( new SpacingBetweenLines { After = "120", Line = "276", LineRule = LineSpacingRuleValues.Auto }))), Style("Title", "Title", 36, Navy, bold: true), Style("Heading1", "Heading 1", 26, Navy, bold: true), Style("Heading2", "Heading 2", 22, Navy, bold: true), Style("Normal", "Normal", 20, TextColor)); styles.Styles.Save(); } private static Style Style(string id, string name, int halfPoints, string color, bool bold = false) { var run = new RunProperties( new RunFonts { Ascii = FontName, HighAnsi = FontName, EastAsia = FontName }, new FontSize { Val = halfPoints.ToString() }, new Color { Val = color }); if (bold) run.Append(new Bold()); return new Style( new StyleName { Val = name }, new BasedOn { Val = "Normal" }, new NextParagraphStyle { Val = "Normal" }, run) { Type = StyleValues.Paragraph, StyleId = id }; } private static void ComposeCover(Body body, Project p, string reportNo) { body.Append( Paragraph("SPC-DynaTest 检测系统", 18, Muted, JustificationValues.Right), Spacer(32), Paragraph("固态功率控制器", 32, Navy, JustificationValues.Center, bold: true), Paragraph("动态参数检测报告", 44, Navy, JustificationValues.Center, bold: true), Spacer(22), KeyValueTable(new[] { ("报告编号", reportNo), ("项目名称", ValueOrDash(p.Name)), ("设备编号", ValueOrDash(p.Dut.SerialNumber)), ("设备名称", ValueOrDash(p.Dut.ProductName)), ("设备型号", ValueOrDash(p.Dut.Model)), ("委托/生产单位", ValueOrDash(p.Dut.Manufacturer)), ("检测日期", p.Site.TestDate.ToString("yyyy-MM-dd")) }), Spacer(18), ConclusionBox(p), Spacer(52), SignatureTable(), PageBreak()); } private void ComposeBody(Body body, MainDocumentPart main, Project p, string reportNo) { body.Append(HeaderParagraph("固态功率控制器动态参数检测报告", reportNo)); int section = 1; if (p.Report.Cover) { Section(body, section++, "检测概况", "TEST OVERVIEW"); body.Append(OverviewTable(p)); Section(body, section++, "结果摘要", "RESULT SUMMARY"); body.Append(SummaryTable(p)); } if (p.Report.Basic && (p.BasicResults.Count > 0 || HasNote(p.TestFunction?.Basic))) { Section(body, section++, "基础测试", "BASIC FUNCTION TEST"); if (p.BasicResults.Count > 0) body.Append(ResultTable( new[] { "通道", "导通电压 V", "导通电流 A", "关后电压 V", "关后电流 A", "判定" }, p.BasicResults.Select(r => new[] { $"CH{r.Channel:00}", Format(r.VoltageOn, "F2"), Format(r.CurrentOn, "F3"), Format(r.VoltageOff, "F2"), Format(r.CurrentOff, "F3"), StatusText(r.Status) }))); ManualNote(body, p.TestFunction?.Basic); } if (p.Report.Curve && (p.CurveResults.Count > 0 || HasNote(p.TestFunction?.Curve))) { Section(body, section++, "开启与关断曲线", "SWITCHING CHARACTERISTICS"); if (p.CurveResults.Count > 0) body.Append(ResultTable( new[] { "通道", "开启时间 ms", "上升时间 ms", "关断时间 ms", "下降时间 ms", "判定" }, p.CurveResults.Select(r => new[] { $"CH{r.Channel:00}", Format(r.OnTimeMs, "F3"), Format(r.RiseTimeMs, "F3"), Format(r.OffTimeMs, "F3"), Format(r.FallTimeMs, "F3"), StatusText(r.Status) }))); ManualNote(body, p.TestFunction?.Curve); foreach (var (channel, images) in CurveImages.OrderBy(x => x.Key)) { AddCurveImage(body, main, channel, "ON", images.Rising); AddCurveImage(body, main, channel, "OFF", images.Falling); } } if (p.Report.Power && (p.PowerResults.Count > 0 || HasNote(p.TestFunction?.Power))) { Section(body, section++, "功率损耗", "POWER LOSS"); if (p.PowerResults.Count > 0) body.Append(ResultTable( new[] { "通道", "输入电压 V", "输入电流 A", "输出电压 V", "输出电流 A", "功率损耗 W", "效率 %", "状态" }, p.PowerResults.Select(r => new[] { $"CH{r.Channel:00}", Format(r.Vin, "F3"), Format(r.Iin, "F3"), Format(r.Vout, "F3"), Format(r.Iout, "F3"), Format(r.PowerLossW, "F3"), Format(r.EfficiencyPct, "F3"), StatusText(r.Status) }))); ManualNote(body, p.TestFunction?.Power); } if (p.Report.Protect && (p.ProtectResults.Count > 0 || HasNote(p.TestFunction?.Protect))) { Section(body, section++, "保护功能", "PROTECTION FUNCTION"); if (p.ProtectResults.Count > 0) body.Append(ResultTable( new[] { "序号", "保护类型", "触发条件", "实测值", "判别模式", "判别结果" }, p.ProtectResults.Select(r => new[] { r.Index > 0 ? r.Index.ToString() : "—", ValueOrDash(r.ProtectType), ValueOrDash(r.Trigger), ValueOrDash(r.MeasuredValue), ValueOrDash(r.JudgeMode), ProtectStatusText(r.Status) }))); ManualNote(body, p.TestFunction?.Protect); } body.Append(Spacer(10), ConclusionBox(p), FooterParagraph()); } private static Paragraph HeaderParagraph(string title, string reportNo) => Paragraph($"{title} 报告编号:{reportNo}", 20, Navy, bold: true); private static void Section(Body body, int number, string title, string english) { body.Append(Spacer(8)); body.Append(Paragraph($"{number:00} {title} {english}", 24, Navy, bold: true)); } private static Table OverviewTable(Project p) => KeyValueGrid(new[] { ("设备编号", p.Dut.SerialNumber), ("设备名称", p.Dut.ProductName), ("设备型号", p.Dut.Model), ("测试人员", p.Site.Tester), ("测试日期", p.Site.TestDate.ToString("yyyy-MM-dd")), ("环境温度", $"{p.Site.TemperatureC:F1} ℃"), ("相对湿度", $"{p.Site.HumidityPct:F1} %RH"), ("环境气压", $"{p.Site.PressureKpa:F1} kPa"), ("项目创建", p.CreatedAt.ToString("yyyy-MM-dd HH:mm")), ("测试备注", p.Site.Remark), ("综合判定", StatusText(p.OverallStatus)) }); private static Table SummaryTable(Project p) { var statuses = p.BasicResults.Select(x => x.Status) .Concat(p.CurveResults.Select(x => x.Status)) .Concat(p.PowerResults.Select(x => x.Status)) .Concat(p.ProtectResults.Select(x => x.Status)) .ToArray(); return KeyValueGrid(new[] { ("检测项目", statuses.Length.ToString()), ("合格", statuses.Count(x => x == TestStatus.Pass).ToString()), ("不合格", statuses.Count(x => x == TestStatus.Fail).ToString()), ("已测", statuses.Count(x => x == TestStatus.Measured).ToString()), ("未测", statuses.Count(x => x == TestStatus.Untested).ToString()), ("综合判定", StatusText(p.OverallStatus)) }); } private static Table SignatureTable() { var table = TableBase(new[] { 1.0, 1.0, 1.0 }); AddRow(table, new[] { "编制:", "审核:", "批准:" }, header: false); AddRow(table, new[] { "日期:", "日期:", "日期:" }, header: false); return table; } private static Table KeyValueTable(IEnumerable<(string Label, string Value)> rows) { var table = TableBase(new[] { 1.1, 3.0 }); foreach (var (label, value) in rows) AddRow(table, new[] { label, value }, labelRow: true); return table; } private static Table KeyValueGrid(IEnumerable<(string Label, string Value)> source) { var rows = source.ToArray(); var table = TableBase(new[] { 1.1, 2.1, 1.1, 2.1 }); for (int i = 0; i < rows.Length; i += 2) { var left = rows[i]; var right = i + 1 < rows.Length ? rows[i + 1] : ("", ""); AddRow(table, new[] { left.Label, ValueOrDash(left.Value), right.Item1, ValueOrDash(right.Item2) }, labelColumns: new[] { 0, 2 }); } return table; } private static Table ResultTable(IReadOnlyList headers, IEnumerable rows) { var table = TableBase(Enumerable.Repeat(1.0, headers.Count).ToArray()); AddRow(table, headers, header: true); foreach (var row in rows) AddRow(table, row, header: false); return table; } private static Table TableBase(IReadOnlyList widths) { var grid = new TableGrid(widths.Select(w => new GridColumn { Width = ((int)(w * 1200)).ToString() })); var table = new Table(); table.Append(new TableProperties( new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct }, new TableBorders( new TopBorder { Val = BorderValues.Single, Color = Line, Size = 4 }, new BottomBorder { Val = BorderValues.Single, Color = Line, Size = 4 }, new LeftBorder { Val = BorderValues.Single, Color = Line, Size = 4 }, new RightBorder { Val = BorderValues.Single, Color = Line, Size = 4 }, new InsideHorizontalBorder { Val = BorderValues.Single, Color = Line, Size = 4 }, new InsideVerticalBorder { Val = BorderValues.Single, Color = Line, Size = 4 }))); table.Append(grid); return table; } private static void AddRow( Table table, IReadOnlyList values, bool header = false, bool labelRow = false, IReadOnlyCollection? labelColumns = null) { var row = new TableRow(); for (int i = 0; i < values.Count; i++) { bool label = labelRow && i == 0 || labelColumns?.Contains(i) == true; row.Append(new TableCell( new TableCellProperties( new Shading { Fill = header ? Navy : label ? PaleBlue : "FFFFFF" }, new TableCellMargin( new TopMargin { Width = "70", Type = TableWidthUnitValues.Dxa }, new BottomMargin { Width = "70", Type = TableWidthUnitValues.Dxa }, new LeftMargin { Width = "90", Type = TableWidthUnitValues.Dxa }, new RightMargin { Width = "90", Type = TableWidthUnitValues.Dxa })), Paragraph(values[i], 17, header ? "FFFFFF" : label ? Navy : TextColor, bold: header || label))); } table.Append(row); } private static Paragraph ConclusionBox(Project p) { string text = $"检测结论:{StatusText(p.OverallStatus)}。{ConclusionText(p.OverallStatus)}"; var paragraph = Paragraph(text, 22, StatusColor(p.OverallStatus), bold: true); paragraph.ParagraphProperties ??= new ParagraphProperties(); paragraph.ParagraphProperties.Append(new Shading { Fill = StatusFill(p.OverallStatus) }); return paragraph; } private static void ManualNote(Body body, string? note) { if (!HasNote(note)) return; body.Append(Paragraph("测试说明 / 现场记录", 18, Navy, bold: true)); body.Append(Paragraph(note!.Trim(), 18, TextColor)); } private static void AddImage(Body body, MainDocumentPart main, byte[] png, string name, double widthCm, double heightCm) { var imagePart = main.AddImagePart(ImagePartType.Png); using (var stream = new MemoryStream(png)) imagePart.FeedData(stream); string id = main.GetIdOfPart(imagePart); long cx = CmToEmu(widthCm); long cy = CmToEmu(heightCm); var drawing = new Drawing( new DW.Inline( new DW.Extent { Cx = cx, Cy = cy }, new DW.EffectExtent { LeftEdge = 0, TopEdge = 0, RightEdge = 0, BottomEdge = 0 }, new DW.DocProperties { Id = (UInt32Value)1U, Name = name }, new DW.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoChangeAspect = true }), new A.Graphic( new A.GraphicData( new PIC.Picture( new PIC.NonVisualPictureProperties( new PIC.NonVisualDrawingProperties { Id = (UInt32Value)0U, Name = name }, new PIC.NonVisualPictureDrawingProperties()), new PIC.BlipFill( new A.Blip { Embed = id }, new A.Stretch(new A.FillRectangle())), new PIC.ShapeProperties( new A.Transform2D( new A.Offset { X = 0, Y = 0 }, new A.Extents { Cx = cx, Cy = cy }), new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle }))) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })) { DistanceFromTop = 0U, DistanceFromBottom = 0U, DistanceFromLeft = 0U, DistanceFromRight = 0U }); body.Append(new Paragraph(new Run(drawing))); } private static void AddCurveImage(Body body, MainDocumentPart main, int channel, string phaseLabel, byte[]? png) { if (png == null || png.Length == 0) return; body.Append(Paragraph($"CH{channel:00} {phaseLabel} Curve", 20, Navy, bold: true)); AddImage(body, main, png, $"CH{channel:00} {phaseLabel} Curve", 15.6, 6.2); } private static Paragraph Paragraph( string text, int halfPoints, string color, JustificationValues? align = null, bool bold = false) { var runProperties = new RunProperties( new RunFonts { Ascii = FontName, HighAnsi = FontName, EastAsia = FontName }, new FontSize { Val = halfPoints.ToString() }, new Color { Val = color }); if (bold) runProperties.Append(new Bold()); return new Paragraph( new ParagraphProperties( new Justification { Val = align ?? JustificationValues.Left }, new SpacingBetweenLines { After = "120" }), new Run(runProperties, new Text(text) { Space = SpaceProcessingModeValues.Preserve })); } private static Paragraph Spacer(int points) => new(new ParagraphProperties(new SpacingBetweenLines { After = (points * 20).ToString() }), new Run(new Text(""))); private static Paragraph PageBreak() => new(new Run(new Break { Type = BreakValues.Page })); private static Paragraph FooterParagraph() => Paragraph($"生成时间:{DateTime.Now:yyyy-MM-dd HH:mm} 本报告仅对所检样品及记录数据负责。", 16, Muted, JustificationValues.Center); private static SectionProperties PageSectionProperties() => new( new PageSize { Width = 11906U, Height = 16838U }, new PageMargin { Top = 900, Right = 850U, Bottom = 900, Left = 850U, Header = 450U, Footer = 450U, Gutter = 0U }); private static long CmToEmu(double cm) => (long)Math.Round(cm * 360000.0); private static string BuildReportNumber(Project p) { string serial = new(p.Dut.SerialNumber.Where(char.IsLetterOrDigit).ToArray()); if (string.IsNullOrWhiteSpace(serial)) serial = "UNKNOWN"; if (serial.Length > 14) serial = serial[^14..]; return $"SPC-{p.Site.TestDate:yyyyMMdd}-{serial.ToUpperInvariant()}"; } private static string Format(double value, string format) => double.IsNaN(value) || double.IsInfinity(value) ? "-" : value.ToString(format); private static bool HasNote(string? value) => !string.IsNullOrWhiteSpace(value); private static string ValueOrDash(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); private static string StatusText(TestStatus status) => status switch { TestStatus.Pass => "合格", TestStatus.Fail => "不合格", TestStatus.Running => "测试中", TestStatus.Measured => "已测", _ => "未测试" }; private static string ProtectStatusText(TestStatus status) => status switch { TestStatus.Pass => "合格", TestStatus.Fail => "不合格", TestStatus.Running => "测试中", _ => "未判定" }; private static string StatusColor(TestStatus status) => status switch { TestStatus.Pass => Success, TestStatus.Fail => Danger, TestStatus.Running => Warning, TestStatus.Measured => Blue, _ => Muted }; private static string StatusFill(TestStatus status) => status switch { TestStatus.Pass => "E8F3EC", TestStatus.Fail => "F8EAEA", TestStatus.Running => "FAF1DE", TestStatus.Measured => PaleBlue, _ => "F1F3F5" }; private static string ConclusionText(TestStatus status) => status switch { TestStatus.Pass => "本次所选检测项目均满足当前判定要求,综合结论为合格。", TestStatus.Fail => "本次检测存在不合格项目,请结合异常通道记录进行复核与处置。", TestStatus.Running => "检测尚未结束,当前结果仅供过程参考。", TestStatus.Measured => "相关项目已完成测量,报告基于记录数据给出测量结果。", _ => "检测数据尚不完整,暂不具备出具最终合格判定的条件。" }; }