Files
XplorePlane/XP.ReportEngine/Services/TxtReportGenerator.cs
T

384 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
{
// 复用基类 ExtractImageReferencesFilePath → 路径引用;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; }
}
}
}