namespace SimpleAdmin.Application; /// /// 负责从 DOCX 中识别占位符并在不依赖 Word 进程的情况下替换文本节点。 /// public static class DocxTemplateProcessor { private static readonly global::System.Text.RegularExpressions.Regex PlaceholderRegex = new( @"\{\{\s*(?[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"; /// /// 扫描正文、表格、页眉和页脚,返回去重后的变量编码。 /// public static List 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(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(); } /// /// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。 /// public static byte[] Render(byte[] docx, IReadOnlyDictionary 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 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() .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 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)); } }