逻辑调整
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user