Files
OnboardXAdmin/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs
T
2026-09-08 17:48:30 +08:00

198 lines
7.9 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));
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>
/// 使用任务变量快照替换 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)”后重新上传");
}
}