384 lines
18 KiB
C#
384 lines
18 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Threading.Tasks;
|
||
using XP.Common.Localization.Interfaces;
|
||
using XP.Common.Logging.Interfaces;
|
||
using XP.ReportEngine.Configs;
|
||
using XP.ReportEngine.Models;
|
||
|
||
namespace XP.ReportEngine.Services
|
||
{
|
||
/// <summary>
|
||
/// TXT(纯文本)报告生成器 | TXT (plain text) report generator
|
||
/// 将 <see cref="ReportContext"/> 渲染为面向人工阅读的纯文本:元数据节、检测结果分组
|
||
/// (处理器类型 + 分类结论 + Data 键值对逐行)、TableRows 等宽列对齐表格,并以可读文本
|
||
/// 记录图像引用(排除任何图像二进制)。
|
||
/// Renders a <see cref="ReportContext"/> into human-readable plain text: a metadata section,
|
||
/// inspection result groups (processor type + classification + Data key-value pairs line by line),
|
||
/// monospace column-aligned tables for TableRows, and readable image references
|
||
/// (excluding any image binary).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// 继承 <see cref="TextReportGeneratorBase"/> 复用 null 校验、日志、编码解析、原子写入与内存流
|
||
/// 返回等公共能力(需求 3、6、9、10)。
|
||
/// Inherits <see cref="TextReportGeneratorBase"/> to reuse common capabilities: null validation,
|
||
/// logging, encoding resolution, atomic write and memory-stream return (Requirements 3, 6, 9, 10).
|
||
/// </remarks>
|
||
public sealed class TxtReportGenerator : TextReportGeneratorBase
|
||
{
|
||
/// <summary>缩进单位(两个空格)| Indentation unit (two spaces)</summary>
|
||
private const string Indent = " ";
|
||
|
||
/// <summary>TXT 表格列分隔符 | TXT table column separator</summary>
|
||
private const string ColumnSeparator = " ";
|
||
|
||
/// <summary>
|
||
/// 构造函数 | Constructor
|
||
/// </summary>
|
||
/// <param name="logger">日志服务 | Logger service</param>
|
||
/// <param name="localization">多语言本地化服务 | Localization service</param>
|
||
/// <param name="config">报告引擎配置 | Report engine configuration</param>
|
||
public TxtReportGenerator(
|
||
ILoggerService logger,
|
||
ILocalizationService localization,
|
||
ReportConfig config)
|
||
: base(logger?.ForModule<TxtReportGenerator>(), localization, config)
|
||
{
|
||
}
|
||
|
||
/// <summary>格式名称 | Format name</summary>
|
||
protected override string FormatName => "TXT";
|
||
|
||
/// <summary>TXT 要求 Metadata 非空(需求 3.8)| TXT requires non-null Metadata (Requirement 3.8)</summary>
|
||
protected override bool RequireNonNullMetadata => true;
|
||
|
||
/// <summary>
|
||
/// 构建 TXT 报告文本内容 | Build the TXT report text content
|
||
/// </summary>
|
||
/// <param name="context">报告上下文(已通过 null 与 Metadata 校验)| Report context (already passed null and Metadata validation)</param>
|
||
/// <returns>报告文本内容 | Report text content</returns>
|
||
protected override Task<string> BuildContentAsync(ReportContext context)
|
||
{
|
||
// 一次性获取当前语言的全部固定标签,保证单次生成语言一致(需求 10.1、10.2)
|
||
// Obtain all fixed labels for the current language once to keep a single generation language-consistent
|
||
// (Requirements 10.1, 10.2)
|
||
var labels = new TxtLabels(Localization);
|
||
|
||
var sb = new StringBuilder();
|
||
|
||
// ① 元数据节(需求 3.2)| ① Metadata section (Requirement 3.2)
|
||
AppendMetadataSection(sb, context.Metadata, labels);
|
||
|
||
// 全局图像引用(如 Logo)排除二进制,仅文本记录(需求 6)| Global image references (e.g. Logo): binaries excluded, text only (Requirement 6)
|
||
AppendImageReferences(sb, context, labels);
|
||
|
||
sb.AppendLine();
|
||
|
||
// ② 检测结果节(需求 3.3、3.7)| ② Inspection results section (Requirements 3.3, 3.7)
|
||
sb.AppendLine(labels.SectionResults);
|
||
sb.AppendLine(new string('=', labels.SectionResults.Length + 4));
|
||
|
||
var groups = context.ResultGroups;
|
||
if (groups == null || groups.Count == 0)
|
||
{
|
||
// 空结果:输出本地化空结果提示(需求 3.7)| Empty result: output localized empty-result hint (Requirement 3.7)
|
||
sb.AppendLine(labels.EmptyResult);
|
||
}
|
||
else
|
||
{
|
||
for (var i = 0; i < groups.Count; i++)
|
||
{
|
||
AppendResultGroup(sb, groups[i], i + 1, labels);
|
||
}
|
||
}
|
||
|
||
return Task.FromResult(sb.ToString());
|
||
}
|
||
|
||
/// <summary>
|
||
/// 追加元数据节 | Append the metadata section
|
||
/// 缺失或空白字段以本地化 N/A 占位(需求 3.2)。
|
||
/// Missing or blank fields are represented with the localized N/A placeholder (Requirement 3.2).
|
||
/// </summary>
|
||
private void AppendMetadataSection(StringBuilder sb, ReportMetadata metadata, TxtLabels labels)
|
||
{
|
||
sb.AppendLine(labels.SectionMetadata);
|
||
sb.AppendLine(new string('=', labels.SectionMetadata.Length + 4));
|
||
|
||
// 报告编号 | Report id
|
||
sb.AppendLine($"{labels.ReportId}: {OrNa(metadata?.ReportId, labels.Na)}");
|
||
|
||
// 检测日期:DateTime 默认值视为缺失(需求 3.2)| Inspection date: default DateTime treated as missing (Requirement 3.2)
|
||
var dateText = metadata != null && metadata.InspectionDate != default
|
||
? metadata.InspectionDate.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||
: labels.Na;
|
||
sb.AppendLine($"{labels.ReportDate}: {dateText}");
|
||
|
||
// 样品名称 | Sample name
|
||
sb.AppendLine($"{labels.ReportSample}: {OrNa(metadata?.SampleName, labels.Na)}");
|
||
|
||
// 操作员名称 | Operator name
|
||
sb.AppendLine($"{labels.ReportOperator}: {OrNa(metadata?.OperatorName, labels.Na)}");
|
||
|
||
// 描述 | Description
|
||
sb.AppendLine($"{labels.ReportDescription}: {OrNa(metadata?.Description, labels.Na)}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 追加图像引用文本(排除二进制,需求 6)| Append image reference texts (binaries excluded, Requirement 6)
|
||
/// </summary>
|
||
private void AppendImageReferences(StringBuilder sb, ReportContext context, TxtLabels labels)
|
||
{
|
||
// 复用基类 ExtractImageReferences:FilePath → 路径引用;Bytes/BitmapSource → 占位标识;空路径 → 跳过
|
||
// Reuse base ExtractImageReferences: FilePath → path reference; Bytes/BitmapSource → placeholder; empty path → skip
|
||
var references = ExtractImageReferences(context).ToList();
|
||
foreach (var reference in references)
|
||
{
|
||
sb.AppendLine(reference);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 追加单个检测结果分组 | Append a single inspection result group
|
||
/// 输出处理器类型、分类结论、Data 键值对逐行,以及 TableRows 等宽列对齐表格(需求 3.3、3.4)。
|
||
/// Outputs processor type, classification, Data key-value pairs line by line, and the
|
||
/// monospace column-aligned table for TableRows (Requirements 3.3, 3.4).
|
||
/// </summary>
|
||
private void AppendResultGroup(StringBuilder sb, InspectionResultGroup group, int index, TxtLabels labels)
|
||
{
|
||
sb.AppendLine();
|
||
// 分组标题:序号 + 处理器类型 | Group title: index + processor type
|
||
sb.AppendLine($"[{index}] {labels.ProcessorType}: {OrNa(group?.ProcessorType, labels.Na)}");
|
||
|
||
// 分类结论 | Classification
|
||
sb.AppendLine($"{Indent}{labels.Classification}: {OrNa(group?.Classification, labels.Na)}");
|
||
|
||
// Data 键值对逐行输出(需求 3.3)| Output Data key-value pairs line by line (Requirement 3.3)
|
||
if (group?.Data != null && group.Data.Count > 0)
|
||
{
|
||
foreach (var kvp in group.Data)
|
||
{
|
||
sb.AppendLine($"{Indent}{kvp.Key}: {FormatValue(kvp.Value)}");
|
||
}
|
||
}
|
||
|
||
// TableRows 等宽列对齐输出(需求 3.4)| Monospace column-aligned output for TableRows (Requirement 3.4)
|
||
if (group?.TableRows != null && group.TableRows.Count > 0)
|
||
{
|
||
AppendTable(sb, group.TableRows);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 等宽列对齐输出表格 | Output a table with monospace column alignment
|
||
/// 先扫描全部行计算各列最大宽度,再按列左对齐填充空格输出;保证每行列数与表头一致(需求 3.4)。
|
||
/// First scans all rows to compute each column's max width, then left-aligns and pads with spaces;
|
||
/// ensures every row has the same number of columns as the header (Requirement 3.4).
|
||
/// </summary>
|
||
private void AppendTable(StringBuilder sb, List<Dictionary<string, object>> tableRows)
|
||
{
|
||
// 计算列的有序并集作为表头(保留首次出现顺序)| Compute ordered union of columns as the header (preserve first-seen order)
|
||
var columns = new List<string>();
|
||
foreach (var row in tableRows)
|
||
{
|
||
if (row == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
foreach (var key in row.Keys)
|
||
{
|
||
if (!columns.Contains(key))
|
||
{
|
||
columns.Add(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (columns.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 构建矩阵化的单元格文本(表头 + 数据行),缺失列留空,保证列数一致
|
||
// Build matrix-form cell texts (header + data rows); missing columns left blank to keep consistent column count
|
||
var matrix = new List<string[]>();
|
||
matrix.Add(columns.ToArray());
|
||
foreach (var row in tableRows)
|
||
{
|
||
var cells = new string[columns.Count];
|
||
for (var c = 0; c < columns.Count; c++)
|
||
{
|
||
if (row != null && row.TryGetValue(columns[c], out var value))
|
||
{
|
||
cells[c] = FormatValue(value);
|
||
}
|
||
else
|
||
{
|
||
cells[c] = string.Empty;
|
||
}
|
||
}
|
||
|
||
matrix.Add(cells);
|
||
}
|
||
|
||
// 先扫描各列最大宽度 | First scan each column's max width
|
||
var widths = new int[columns.Count];
|
||
foreach (var cells in matrix)
|
||
{
|
||
for (var c = 0; c < columns.Count; c++)
|
||
{
|
||
var len = GetDisplayWidth(cells[c]);
|
||
if (len > widths[c])
|
||
{
|
||
widths[c] = len;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 按列左对齐填充空格输出 | Output left-aligned and space-padded by column
|
||
foreach (var cells in matrix)
|
||
{
|
||
var lineParts = new string[columns.Count];
|
||
for (var c = 0; c < columns.Count; c++)
|
||
{
|
||
var cell = cells[c] ?? string.Empty;
|
||
var pad = widths[c] - GetDisplayWidth(cell);
|
||
lineParts[c] = pad > 0 ? cell + new string(' ', pad) : cell;
|
||
}
|
||
|
||
// 行首缩进,列间以固定分隔符连接,并去除行尾多余空白 | Indent line, join columns with a fixed separator, trim trailing whitespace
|
||
sb.AppendLine((Indent + string.Join(ColumnSeparator, lineParts)).TrimEnd());
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算字符串的显示宽度(CJK 全角字符按 2 个宽度计)| Compute display width (CJK full-width chars count as 2)
|
||
/// 用于等宽对齐时使中英文混排尽量对齐。
|
||
/// Used to align mixed Chinese/English text as evenly as possible under monospace alignment.
|
||
/// </summary>
|
||
private static int GetDisplayWidth(string text)
|
||
{
|
||
if (string.IsNullOrEmpty(text))
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
var width = 0;
|
||
foreach (var ch in text)
|
||
{
|
||
width += IsFullWidth(ch) ? 2 : 1;
|
||
}
|
||
|
||
return width;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断字符是否为全角(CJK 等)| Determine whether a character is full-width (CJK, etc.)
|
||
/// </summary>
|
||
private static bool IsFullWidth(char ch)
|
||
{
|
||
// 覆盖常见 CJK 区间与全角符号 | Cover common CJK ranges and full-width symbols
|
||
return (ch >= 0x1100 && ch <= 0x115F) // 韩文字母 | Hangul Jamo
|
||
|| (ch >= 0x2E80 && ch <= 0x303E) // CJK 部首/标点 | CJK radicals/punctuation
|
||
|| (ch >= 0x3041 && ch <= 0x33FF) // 假名/CJK 符号 | Kana/CJK symbols
|
||
|| (ch >= 0x3400 && ch <= 0x4DBF) // CJK 扩展 A | CJK Ext A
|
||
|| (ch >= 0x4E00 && ch <= 0x9FFF) // CJK 统一汉字 | CJK Unified Ideographs
|
||
|| (ch >= 0xA000 && ch <= 0xA4CF) // 彝文 | Yi
|
||
|| (ch >= 0xAC00 && ch <= 0xD7A3) // 韩文音节 | Hangul syllables
|
||
|| (ch >= 0xF900 && ch <= 0xFAFF) // CJK 兼容汉字 | CJK compatibility ideographs
|
||
|| (ch >= 0xFE30 && ch <= 0xFE4F) // CJK 兼容符号 | CJK compatibility forms
|
||
|| (ch >= 0xFF00 && ch <= 0xFF60) // 全角 ASCII | Full-width ASCII
|
||
|| (ch >= 0xFFE0 && ch <= 0xFFE6); // 全角货币符号 | Full-width currency symbols
|
||
}
|
||
|
||
/// <summary>
|
||
/// 格式化值为字符串,null 转空串,嵌套字典/集合递归展开 | Format a value to string, converting null to empty, recursively expanding nested dictionaries/collections
|
||
/// </summary>
|
||
private static string FormatValue(object value)
|
||
{
|
||
if (value == null)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
// 嵌套字典:展开为 "key=value, key=value" 形式 | Nested dictionary: expand to "key=value, key=value" form
|
||
if (value is IDictionary<string, object> dict)
|
||
{
|
||
var parts = new List<string>();
|
||
foreach (var kvp in dict)
|
||
{
|
||
parts.Add($"{kvp.Key}={FormatValue(kvp.Value)}");
|
||
}
|
||
return string.Join(", ", parts);
|
||
}
|
||
|
||
// 集合类型(非字符串):逐项格式化后以逗号分隔 | Collection type (non-string): format each item separated by comma
|
||
if (value is System.Collections.IEnumerable enumerable && !(value is string))
|
||
{
|
||
var items = new List<string>();
|
||
foreach (var item in enumerable)
|
||
{
|
||
items.Add(FormatValue(item));
|
||
}
|
||
return $"[{string.Join(", ", items)}]";
|
||
}
|
||
|
||
if (value is IFormattable formattable)
|
||
{
|
||
return formattable.ToString(null, CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
return value.ToString();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 返回非空白文本,否则返回 N/A 占位(需求 3.2)| Return non-blank text, otherwise the N/A placeholder (Requirement 3.2)
|
||
/// </summary>
|
||
private static string OrNa(string text, string na)
|
||
{
|
||
return string.IsNullOrWhiteSpace(text) ? na : text;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 一次性解析的本地化标签集合 | Localized label set resolved once
|
||
/// 在报告生成开始时一次性确定语言并缓存全部固定标签,确保单次生成语言一致(需求 10.2)。
|
||
/// Resolves the language once at the start of generation and caches all fixed labels to keep
|
||
/// a single generation language-consistent (Requirement 10.2).
|
||
/// </summary>
|
||
private sealed class TxtLabels
|
||
{
|
||
public TxtLabels(ILocalizationService localization)
|
||
{
|
||
Na = localization.GetString("Common_NA");
|
||
EmptyResult = localization.GetString("Report_EmptyResult");
|
||
SectionMetadata = localization.GetString("Txt_Section_Metadata");
|
||
SectionResults = localization.GetString("Txt_Section_Results");
|
||
ReportId = localization.GetString("Report_Id");
|
||
ReportDate = localization.GetString("Report_Date");
|
||
ReportSample = localization.GetString("Report_Sample");
|
||
ReportOperator = localization.GetString("Report_Operator");
|
||
ReportDescription = localization.GetString("Report_Description");
|
||
ProcessorType = localization.GetString("Field_ProcessorType");
|
||
Classification = localization.GetString("Field_Classification");
|
||
}
|
||
|
||
public string Na { get; }
|
||
public string EmptyResult { get; }
|
||
public string SectionMetadata { get; }
|
||
public string SectionResults { get; }
|
||
public string ReportId { get; }
|
||
public string ReportDate { get; }
|
||
public string ReportSample { get; }
|
||
public string ReportOperator { get; }
|
||
public string ReportDescription { get; }
|
||
public string ProcessorType { get; }
|
||
public string Classification { get; }
|
||
}
|
||
}
|
||
}
|