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) { 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(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 后上传"); } } /// /// 占位符提取正则只会匹配正确格式,因此必须先主动检查错误格式, /// 避免缺少花括号或变量编码写错时被静默忽略并继续创建版本。 /// 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}”"; } /// /// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。 /// public static byte[] Render(byte[] docx, IReadOnlyDictionary 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 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)); } /// /// 根据文件头识别真实格式,避免只修改扩展名的旧版 DOC 在解压时变成系统异常。 /// 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)”后重新上传"); } }