Files
OnboardXAdmin/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs
T
2026-08-25 10:20:53 +08:00

160 lines
6.1 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)
{
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
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();
}
/// <summary>
/// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。
/// </summary>
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
{
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));
}
}