Files
XplorePlane/XP.ReportEngine/Services/ExcelSheetNameSanitizer.cs
T
QI Mingxuan d72b583319 [XP.ReportEngine] 新增 TXT/CSV/Excel 三种报告输出格式支持;Excel (.xlsx) 报告生成器,基于 ClosedXML,支持结构化生成和模板填充两种模式; 新增 XplorePlane_ReportTemplate.xlsx:Excel 报告模板文件。
[XP.ReportEngine] 模型与配置扩展:ReportOutputFormat 枚举:新增 Excel=1, Csv=2, Txt=3(显式序号,向后兼容);新增多格式相关配置加载。
2026-07-01 11:31:41 +08:00

197 lines
8.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
namespace XP.ReportEngine.Services
{
/// <summary>
/// Excel 工作表名称规整器 | Excel worksheet name sanitizer
/// 为 ExcelReportGenerator 提供工作表名称规整逻辑(需求 11.4)
/// Provides worksheet name sanitization logic for ExcelReportGenerator (Requirement 11.4)
/// </summary>
/// <remarks>
/// Excel 工作表名规则 | Excel worksheet name rules:
/// - 长度上限 31 个字符 | Maximum length is 31 characters
/// - 禁止字符:: \ / ? * [ ] | Illegal characters: : \ / ? * [ ]
/// - 不能为空 | Cannot be empty
/// - 工作簿内须唯一(不区分大小写,符合 Excel 语义)| Must be unique within the workbook (case-insensitive, matching Excel semantics)
///
/// 设计为静态帮助类,便于 ExcelReportGenerator(任务 7.3)复用。
/// Designed as a static helper so ExcelReportGenerator (task 7.3) can reuse it.
/// </remarks>
internal static class ExcelSheetNameSanitizer
{
/// <summary>
/// Excel 工作表名称长度上限 | Maximum length of an Excel worksheet name
/// </summary>
private const int MaxSheetNameLength = 31;
/// <summary>
/// Excel 工作表名称非法字符集合 | Set of illegal characters for Excel worksheet names
/// : \ / ? * [ ]
/// </summary>
private static readonly char[] IllegalChars = { ':', '\\', '/', '?', '*', '[', ']' };
/// <summary>
/// 规整工作表名称为合法且工作簿内唯一的名称,并将结果记入 <paramref name="usedNames"/>。
/// Sanitizes a worksheet name into a legal, workbook-unique name and records the result into <paramref name="usedNames"/>.
/// </summary>
/// <param name="processorType">
/// 名称来源,优先使用处理器类型;为空则使用 Group_{序号}。
/// Name source; prefer the processor type. If empty, falls back to Group_{index}.
/// </param>
/// <param name="groupIndex">
/// 分组序号,当 <paramref name="processorType"/> 为空时用于生成默认名称。
/// Group index, used to build the default name when <paramref name="processorType"/> is empty.
/// </param>
/// <param name="usedNames">
/// 工作簿内已使用的名称集合(不区分大小写),用于去重。方法会将最终选定的名称加入该集合。
/// The set of names already used in the workbook (case-insensitive), used for deduplication.
/// The method adds the finally chosen name into this set.
/// </param>
/// <returns>合法且唯一的工作表名称 | A legal and unique worksheet name</returns>
public static string SanitizeSheetName(string processorType, int groupIndex, ISet<string> usedNames)
{
if (usedNames == null)
{
throw new ArgumentNullException(nameof(usedNames));
}
// 1. 确定名称来源:优先 ProcessorType,为空用 Group_{序号}
// Determine name source: prefer ProcessorType, fall back to Group_{index}
string source = string.IsNullOrWhiteSpace(processorType)
? $"Group_{groupIndex}"
: processorType.Trim();
// 2. 替换非法字符为 '_'
// Replace illegal characters with '_'
string sanitized = ReplaceIllegalChars(source);
// 若替换后为空白(例如全为空白字符),回退到默认名
// If it becomes blank after replacement (e.g. all whitespace), fall back to the default name
if (string.IsNullOrWhiteSpace(sanitized))
{
sanitized = ReplaceIllegalChars($"Group_{groupIndex}");
}
// 3. 截断至 31 字符
// Truncate to 31 characters
sanitized = Truncate(sanitized, MaxSheetNameLength);
// 4. 去重:为空或重复时追加序号 _{n},并保证总长 <= 31(必要时截断前缀)
// Deduplicate: if empty or duplicate, append suffix _{n} while keeping total length <= 31 (truncate prefix as needed)
string unique = EnsureUnique(sanitized, usedNames);
// 记录最终名称 | Record the final name
usedNames.Add(unique);
return unique;
}
/// <summary>
/// 将名称中的 Excel 非法字符替换为下划线 | Replaces Excel-illegal characters in the name with underscores
/// </summary>
private static string ReplaceIllegalChars(string name)
{
var builder = new StringBuilder(name.Length);
foreach (char c in name)
{
builder.Append(IsIllegal(c) ? '_' : c);
}
return builder.ToString();
}
/// <summary>
/// 判断字符是否为 Excel 工作表名非法字符 | Determines whether a character is illegal in an Excel worksheet name
/// </summary>
private static bool IsIllegal(char c)
{
foreach (char illegal in IllegalChars)
{
if (c == illegal)
{
return true;
}
}
return false;
}
/// <summary>
/// 将字符串截断至指定最大长度 | Truncates a string to the specified maximum length
/// </summary>
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value ?? string.Empty;
}
return value.Substring(0, maxLength);
}
/// <summary>
/// 保证名称在工作簿内唯一(不区分大小写)。为空或重复时追加去重序号 _{n},
/// 并保证总长不超过 31 个字符(必要时截断前缀)。
/// Ensures the name is unique within the workbook (case-insensitive). When empty or duplicate,
/// appends a dedup suffix _{n} while keeping the total length within 31 characters (truncating the prefix as needed).
/// </summary>
private static string EnsureUnique(string baseName, ISet<string> usedNames)
{
// 若名称非空且不冲突,直接使用 | If the name is non-empty and does not conflict, use it directly
if (!string.IsNullOrEmpty(baseName) && !IsUsed(baseName, usedNames))
{
return baseName;
}
// 名称为空或冲突:追加去重序号 | Name empty or conflicting: append a dedup suffix
for (int n = 1; n <= int.MaxValue; n++)
{
string suffix = "_" + n.ToString(System.Globalization.CultureInfo.InvariantCulture);
// 计算可用于前缀的最大长度,保证 前缀 + 后缀 <= 31
// Compute the max prefix length so that prefix + suffix <= 31
int allowedPrefixLength = MaxSheetNameLength - suffix.Length;
if (allowedPrefixLength < 0)
{
allowedPrefixLength = 0;
}
string prefix = Truncate(baseName, allowedPrefixLength);
// 若前缀为空(例如 baseName 原本为空),仅使用去重序号本身作为候选名
// If prefix is empty (e.g. baseName was empty), use the dedup suffix itself as the candidate
string candidate = prefix.Length == 0
? Truncate("Sheet" + suffix, MaxSheetNameLength)
: prefix + suffix;
if (!IsUsed(candidate, usedNames))
{
return candidate;
}
}
// 理论上不可达 | Practically unreachable
throw new InvalidOperationException(
"无法为工作表生成唯一名称 | Unable to generate a unique worksheet name");
}
/// <summary>
/// 判断名称是否已被使用(不区分大小写,符合 Excel 语义)
/// Determines whether a name has already been used (case-insensitive, matching Excel semantics)
/// </summary>
private static bool IsUsed(string name, ISet<string> usedNames)
{
foreach (string used in usedNames)
{
if (string.Equals(used, name, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
}