265 lines
11 KiB
C#
265 lines
11 KiB
C#
namespace SimpleAdmin.Application;
|
|
|
|
/// <summary>
|
|
/// 负责从 DOCX 中识别占位符并在不依赖 Word 进程的情况下替换文本节点。
|
|
/// </summary>
|
|
public static class DocxTemplateProcessor
|
|
{
|
|
private static readonly global::System.Text.RegularExpressions.Regex PlaceholderRegex = new(
|
|
@"\{\{\s*(?<code>[A-Za-z][A-Za-z0-9_.-]*)\s*\}\}",
|
|
global::System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
private static readonly global::System.Xml.Linq.XNamespace WordNamespace =
|
|
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
|
|
/// <summary>
|
|
/// 扫描正文、表格、页眉和页脚,返回去重后的变量编码。
|
|
/// </summary>
|
|
public static List<string> ExtractVariableCodes(byte[] docx)
|
|
{
|
|
ValidateDocxHeader(docx);
|
|
try
|
|
{
|
|
using var input = new MemoryStream(docx);
|
|
using var archive = new global::System.IO.Compression.ZipArchive(
|
|
input,
|
|
global::System.IO.Compression.ZipArchiveMode.Read);
|
|
if (archive.GetEntry("word/document.xml") == null)
|
|
throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
|
|
|
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
|
|
{
|
|
using var stream = entry.Open();
|
|
var document = global::System.Xml.Linq.XDocument.Load(stream);
|
|
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
|
|
{
|
|
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
|
|
ValidatePlaceholderSyntax(text);
|
|
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
|
|
codes.Add(match.Groups["code"].Value.Trim());
|
|
}
|
|
}
|
|
|
|
return codes.OrderBy(code => code).ToList();
|
|
}
|
|
catch (global::System.IO.InvalidDataException)
|
|
{
|
|
throw Oops.Bah("DOCX 文件结构已经损坏或上传不完整,请重新另存为 DOCX 后上传");
|
|
}
|
|
catch (global::System.Xml.XmlException)
|
|
{
|
|
throw Oops.Bah("DOCX 文件内部内容损坏,Word 可以尝试修复该文件;请修复并重新另存为 DOCX 后上传");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 占位符提取正则只会匹配正确格式,因此必须先主动检查错误格式,
|
|
/// 避免缺少花括号或变量编码写错时被静默忽略并继续创建版本。
|
|
/// </summary>
|
|
private static void ValidatePlaceholderSyntax(string text)
|
|
{
|
|
if (string.IsNullOrEmpty(text))
|
|
return;
|
|
|
|
if (text.Contains("{{", StringComparison.Ordinal)
|
|
|| text.Contains("}}", StringComparison.Ordinal))
|
|
{
|
|
throw Oops.Bah(
|
|
"DOCX 占位符格式错误:检测到中文全角花括号。请使用英文半角双花括号,例如 {{employee.EmployeeName}}。");
|
|
}
|
|
|
|
if (text.Contains("{{{", StringComparison.Ordinal)
|
|
|| text.Contains("}}}", StringComparison.Ordinal))
|
|
{
|
|
throw Oops.Bah(
|
|
"DOCX 占位符格式错误:检测到多余的花括号。占位符必须使用成对的英文半角双花括号,例如 {{employee.EmployeeName}}。");
|
|
}
|
|
|
|
var searchStart = 0;
|
|
while (searchStart < text.Length)
|
|
{
|
|
var openIndex = text.IndexOf("{{", searchStart, StringComparison.Ordinal);
|
|
var closeIndex = text.IndexOf("}}", searchStart, StringComparison.Ordinal);
|
|
|
|
if (closeIndex >= 0 && (openIndex < 0 || closeIndex < openIndex))
|
|
{
|
|
throw Oops.Bah(
|
|
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, closeIndex)} 缺少对应的起始符“{{{{”。正确格式示例:{{{{employee.EmployeeName}}}}。");
|
|
}
|
|
|
|
if (openIndex < 0)
|
|
return;
|
|
|
|
closeIndex = text.IndexOf("}}", openIndex + 2, StringComparison.Ordinal);
|
|
if (closeIndex < 0)
|
|
{
|
|
throw Oops.Bah(
|
|
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, openIndex)} 缺少结束符“}}}}”。正确格式示例:{{{{employee.EmployeeName}}}}。");
|
|
}
|
|
|
|
var placeholder = text.Substring(openIndex, closeIndex + 2 - openIndex);
|
|
var match = PlaceholderRegex.Match(placeholder);
|
|
if (!match.Success || match.Length != placeholder.Length)
|
|
{
|
|
throw Oops.Bah(
|
|
$"DOCX 占位符格式错误:“{placeholder}”。变量编码只能包含英文字母、数字、下划线、点和短横线,并且必须使用成对的英文半角双花括号。");
|
|
}
|
|
|
|
searchStart = closeIndex + 2;
|
|
}
|
|
}
|
|
|
|
private static string BuildPlaceholderPreview(string text, int markerIndex)
|
|
{
|
|
const int maxLength = 60;
|
|
var preview = text[markerIndex..].Trim();
|
|
if (preview.Length > maxLength)
|
|
preview = preview[..maxLength] + "…";
|
|
return $"“{preview}”";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。
|
|
/// </summary>
|
|
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
|
|
{
|
|
ValidateDocxHeader(docx);
|
|
using var input = new MemoryStream(docx);
|
|
using var archive = new global::System.IO.Compression.ZipArchive(
|
|
input,
|
|
global::System.IO.Compression.ZipArchiveMode.Read);
|
|
using var output = new MemoryStream();
|
|
using var target = new global::System.IO.Compression.ZipArchive(
|
|
output,
|
|
global::System.IO.Compression.ZipArchiveMode.Create,
|
|
true);
|
|
|
|
foreach (var entry in archive.Entries)
|
|
{
|
|
var next = target.CreateEntry(
|
|
entry.FullName,
|
|
global::System.IO.Compression.CompressionLevel.Optimal);
|
|
using var sourceStream = entry.Open();
|
|
using var targetStream = next.Open();
|
|
if (!IsWordTextXml(entry.FullName))
|
|
{
|
|
sourceStream.CopyTo(targetStream);
|
|
continue;
|
|
}
|
|
|
|
var document = global::System.Xml.Linq.XDocument.Load(sourceStream);
|
|
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
|
|
ReplaceParagraphVariables(paragraph, values);
|
|
document.Save(targetStream, global::System.Xml.Linq.SaveOptions.DisableFormatting);
|
|
}
|
|
|
|
target.Dispose();
|
|
return output.ToArray();
|
|
}
|
|
|
|
private static void ReplaceParagraphVariables(
|
|
global::System.Xml.Linq.XElement paragraph,
|
|
IReadOnlyDictionary<string, string> values)
|
|
{
|
|
var replacements = 0;
|
|
while (replacements < 1000)
|
|
{
|
|
var nodes = paragraph.Descendants(WordNamespace + "t").ToList();
|
|
var text = string.Concat(nodes.Select(node => node.Value));
|
|
var match = PlaceholderRegex.Matches(text)
|
|
.Cast<global::System.Text.RegularExpressions.Match>()
|
|
.LastOrDefault(item => values.ContainsKey(item.Groups["code"].Value.Trim()));
|
|
if (match == null)
|
|
return;
|
|
|
|
var code = match.Groups["code"].Value.Trim();
|
|
ReplaceRange(nodes, match.Index, match.Length, values[code] ?? string.Empty);
|
|
replacements++;
|
|
}
|
|
|
|
throw Oops.Bah("合同模板单个段落中的变量数量异常");
|
|
}
|
|
|
|
private static void ReplaceRange(
|
|
List<global::System.Xml.Linq.XElement> nodes,
|
|
int start,
|
|
int length,
|
|
string replacement)
|
|
{
|
|
var end = start + length;
|
|
var offset = 0;
|
|
global::System.Xml.Linq.XElement startNode = null;
|
|
global::System.Xml.Linq.XElement endNode = null;
|
|
var startIndex = 0;
|
|
var endIndex = 0;
|
|
|
|
foreach (var node in nodes)
|
|
{
|
|
var nextOffset = offset + node.Value.Length;
|
|
if (startNode == null && start >= offset && start < nextOffset)
|
|
{
|
|
startNode = node;
|
|
startIndex = start - offset;
|
|
}
|
|
|
|
if (end > offset && end <= nextOffset)
|
|
{
|
|
endNode = node;
|
|
endIndex = end - offset;
|
|
break;
|
|
}
|
|
|
|
offset = nextOffset;
|
|
}
|
|
|
|
if (startNode == null || endNode == null)
|
|
return;
|
|
|
|
if (ReferenceEquals(startNode, endNode))
|
|
{
|
|
startNode.Value = startNode.Value[..startIndex]
|
|
+ replacement
|
|
+ startNode.Value[endIndex..];
|
|
return;
|
|
}
|
|
|
|
var startNodePosition = nodes.IndexOf(startNode);
|
|
var endNodePosition = nodes.IndexOf(endNode);
|
|
startNode.Value = startNode.Value[..startIndex] + replacement;
|
|
for (var index = startNodePosition + 1; index < endNodePosition; index++)
|
|
nodes[index].Value = string.Empty;
|
|
endNode.Value = endNode.Value[endIndex..];
|
|
}
|
|
|
|
private static bool IsWordTextXml(string entryName)
|
|
{
|
|
return entryName == "word/document.xml"
|
|
|| ((entryName.StartsWith("word/header", StringComparison.OrdinalIgnoreCase)
|
|
|| entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase))
|
|
&& entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 根据文件头识别真实格式,避免只修改扩展名的旧版 DOC 在解压时变成系统异常。
|
|
/// </summary>
|
|
private static void ValidateDocxHeader(byte[] docx)
|
|
{
|
|
if (docx == null || docx.Length < 4)
|
|
throw Oops.Bah("上传的 DOCX 文件为空或上传不完整,请重新选择文件上传");
|
|
|
|
// 旧版 Word 97-2003 二进制 DOC 使用 OLE Compound File 文件头:D0 CF 11 E0。
|
|
if (docx[0] == 0xD0
|
|
&& docx[1] == 0xCF
|
|
&& docx[2] == 0x11
|
|
&& docx[3] == 0xE0)
|
|
{
|
|
throw Oops.Bah("上传文件实际是旧版 DOC 格式,不能直接修改后缀名;请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
|
}
|
|
|
|
// DOCX 是 Open XML ZIP 包,正常文件以 PK 开头。
|
|
if (docx[0] != 0x50 || docx[1] != 0x4B)
|
|
throw Oops.Bah("上传文件的真实格式不是 DOCX,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
|
}
|
|
}
|