赛事证书
This commit is contained in:
parent
7fd1cd1435
commit
2ee874554d
@ -12,6 +12,7 @@ using System.Diagnostics;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using DocumentFormat.OpenXml.Drawing.Wordprocessing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using QuestPDF.Fluent;
|
||||
@ -179,8 +180,8 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
qrCodeImage = memoryStream.ToArray();
|
||||
}
|
||||
|
||||
// 12. 转换为PDF(包含二维码)
|
||||
var pdfStream = await ConvertDocToPdf(processedStream, qrCodeImage);
|
||||
// 12. 转换为PDF(包含二维码和数据替换)
|
||||
var pdfStream = await ConvertDocToPdf(processedStream, qrCodeImage, input.Data, qrCodeData, placeholderMapping);
|
||||
|
||||
// 13. 保存PDF文件到预设地址
|
||||
await SavePdfFileToUrl(pdfStream, pdfUrl);
|
||||
@ -262,128 +263,11 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 转换DOC为PDF(使用LibreOffice,保持原始Word布局)
|
||||
/// 转换DOC为PDF(使用QuestPDF)
|
||||
/// </summary>
|
||||
private async Task<Stream> ConvertDocToPdf(Stream docStream, byte[] qrCodeImage = null)
|
||||
private async Task<Stream> ConvertDocToPdf(Stream docStream, byte[] qrCodeImage = null, Dictionary<string, string> data = null, Dictionary<string, string> qrCodeData = null, Dictionary<string, string> placeholderMapping = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("开始DOCX转PDF转换(使用LibreOffice)");
|
||||
|
||||
// 重置流位置
|
||||
docStream.Position = 0;
|
||||
|
||||
// 如果有二维码,先把二维码加到DOCX中
|
||||
if (qrCodeImage != null)
|
||||
{
|
||||
docStream = await AddQRCodeToDocx(docStream, qrCodeImage);
|
||||
}
|
||||
|
||||
// 使用LibreOffice转换
|
||||
var pdfStream = await ConvertDocToPdfWithLibreOffice(docStream);
|
||||
|
||||
pdfStream.Position = 0;
|
||||
_logger.LogInformation("DOCX转PDF完成,PDF大小: {Size} bytes", pdfStream.Length);
|
||||
return pdfStream;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DOCX转PDF失败(LibreOffice),尝试使用QuestPDF后备方案");
|
||||
|
||||
// 如果LibreOffice失败,使用QuestPDF作为后备
|
||||
docStream.Position = 0;
|
||||
return await ConvertDocToPdfWithQuestPDF(docStream, qrCodeImage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用LibreOffice转换DOCX到PDF
|
||||
/// </summary>
|
||||
private async Task<Stream> ConvertDocToPdfWithLibreOffice(Stream docStream)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "YD_Event_Certs_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
// 保存临时DOCX文件
|
||||
var docxPath = Path.Combine(tempDir, "template.docx");
|
||||
await using (var fileStream = File.Create(docxPath))
|
||||
{
|
||||
await docStream.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
// 使用LibreOffice转换
|
||||
var libreOfficePath = GetLibreOfficePath();
|
||||
var pdfPath = Path.Combine(tempDir, "output.pdf");
|
||||
|
||||
var arguments = $"--headless --convert-to pdf --outdir \"{tempDir}\" \"{docxPath}\"";
|
||||
|
||||
_logger.LogInformation("启动LibreOffice: {Path} {Args}", libreOfficePath, arguments);
|
||||
|
||||
var processInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = libreOfficePath,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(processInfo);
|
||||
if (process == null)
|
||||
{
|
||||
throw new Exception("无法启动LibreOffice进程");
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var error = await process.StandardError.ReadToEndAsync();
|
||||
_logger.LogError("LibreOffice转换失败: {Error}", error);
|
||||
throw new Exception($"LibreOffice转换失败: {error}");
|
||||
}
|
||||
|
||||
// 读取生成的PDF
|
||||
if (!File.Exists(pdfPath))
|
||||
{
|
||||
throw new Exception("LibreOffice未生成PDF文件");
|
||||
}
|
||||
|
||||
var pdfStream = new MemoryStream();
|
||||
await using (var fileStream = File.OpenRead(pdfPath))
|
||||
{
|
||||
await fileStream.CopyToAsync(pdfStream);
|
||||
}
|
||||
|
||||
pdfStream.Position = 0;
|
||||
return pdfStream;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 清理临时文件
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "清理临时文件失败: {Path}", tempDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用QuestPDF作为后备方案
|
||||
/// </summary>
|
||||
private async Task<Stream> ConvertDocToPdfWithQuestPDF(Stream docStream, byte[] qrCodeImage = null)
|
||||
{
|
||||
_logger.LogInformation("使用QuestPDF作为后备方案");
|
||||
_logger.LogInformation("开始DOCX转PDF转换(使用QuestPDF)");
|
||||
|
||||
// 重置流位置
|
||||
docStream.Position = 0;
|
||||
@ -401,35 +285,97 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
documentData.QRCodeMimeType = "image/png";
|
||||
}
|
||||
|
||||
// 替换占位符内容
|
||||
if (data != null)
|
||||
{
|
||||
ReplacePlaceholdersInDocumentData(documentData, data, qrCodeData, placeholderMapping);
|
||||
}
|
||||
|
||||
// 生成PDF
|
||||
var pdfStream = new MemoryStream();
|
||||
await GeneratePdfWithQuestPDF(pdfStream, documentData);
|
||||
|
||||
pdfStream.Position = 0;
|
||||
_logger.LogInformation("DOCX转PDF完成,PDF大小: {Size} bytes", pdfStream.Length);
|
||||
return pdfStream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加二维码到DOCX(简单实现,实际项目中需要完善)
|
||||
/// 替换DocumentData中的占位符
|
||||
/// </summary>
|
||||
private async Task<Stream> AddQRCodeToDocx(Stream docStream, byte[] qrCodeImage)
|
||||
private void ReplacePlaceholdersInDocumentData(DocumentData documentData, Dictionary<string, string> data, Dictionary<string, string> qrCodeData, Dictionary<string, string> placeholderMapping)
|
||||
{
|
||||
// 注意:这里简化实现,实际项目中需要:
|
||||
// 1. 查找DOCX中的二维码占位符
|
||||
// 2. 将占位符替换为二维码图片
|
||||
// 3. 正确处理图片大小和位置
|
||||
const string placeholderPattern = @"\{\{([^}]+)\}\}";
|
||||
|
||||
// 临时方案:暂时不添加二维码到DOCX,使用QuestPDF的后备方案处理
|
||||
_logger.LogWarning("添加二维码到DOCX功能待实现,将使用QuestPDF后备方案");
|
||||
// 记录占位符映射信息(便于调试)
|
||||
if (placeholderMapping != null && placeholderMapping.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("占位符映射配置: {Mapping}", string.Join(", ", placeholderMapping.Select(kvp => $"{kvp.Key}->{kvp.Value}")));
|
||||
}
|
||||
|
||||
// 直接返回原始流,后续会使用QuestPDF处理
|
||||
var resultStream = new MemoryStream();
|
||||
docStream.Position = 0;
|
||||
await docStream.CopyToAsync(resultStream);
|
||||
resultStream.Position = 0;
|
||||
return resultStream;
|
||||
// 记录数据字典中的key(便于调试)
|
||||
if (data != null && data.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("数据字典中的key: {Keys}", string.Join(", ", data.Keys));
|
||||
}
|
||||
|
||||
foreach (var paragraph in documentData.Paragraphs)
|
||||
{
|
||||
var originalText = paragraph.Text;
|
||||
var processedText = originalText;
|
||||
|
||||
// 使用正则表达式替换所有占位符
|
||||
processedText = System.Text.RegularExpressions.Regex.Replace(processedText, placeholderPattern, match =>
|
||||
{
|
||||
var key = match.Groups[1].Value.Trim();
|
||||
_logger.LogDebug("处理占位符: {Key}", key);
|
||||
|
||||
// 优先使用二维码数据
|
||||
if (qrCodeData != null && qrCodeData.ContainsKey(key))
|
||||
{
|
||||
_logger.LogDebug(" -> 使用二维码数据: {Value}", qrCodeData[key]);
|
||||
return qrCodeData[key];
|
||||
}
|
||||
|
||||
// 尝试使用占位符映射(中文显示名 -> 英文键名)
|
||||
string mappedKey = key;
|
||||
if (placeholderMapping != null && placeholderMapping.ContainsKey(key))
|
||||
{
|
||||
mappedKey = placeholderMapping[key];
|
||||
_logger.LogDebug(" -> 占位符映射: {Original} -> {Mapped}", key, mappedKey);
|
||||
}
|
||||
|
||||
// 使用普通数据(先尝试映射后的key,再尝试原始key)
|
||||
if (data != null)
|
||||
{
|
||||
if (data.ContainsKey(mappedKey))
|
||||
{
|
||||
_logger.LogDebug(" -> 使用数据字典(映射后): {Key} = {Value}", mappedKey, data[mappedKey]);
|
||||
return data[mappedKey] ?? string.Empty;
|
||||
}
|
||||
|
||||
if (data.ContainsKey(key))
|
||||
{
|
||||
_logger.LogDebug(" -> 使用数据字典(原始): {Key} = {Value}", key, data[key]);
|
||||
return data[key] ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// 返回原始占位符(未找到数据)
|
||||
_logger.LogDebug(" -> 未找到数据,保留原始占位符");
|
||||
return match.Value;
|
||||
});
|
||||
|
||||
if (originalText != processedText)
|
||||
{
|
||||
_logger.LogDebug("占位符替换: {Original} -> {Processed}", originalText, processedText);
|
||||
paragraph.Text = processedText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 提取DOCX文档数据(包括段落、表格、背景图片等)
|
||||
/// </summary>
|
||||
@ -445,26 +391,32 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
|
||||
if (body != null)
|
||||
{
|
||||
// 首先处理 Drawing 对象(浮于文字上方的文本框)
|
||||
ExtractDrawingObjects(body, documentData);
|
||||
|
||||
// 提取段落(包含位置信息)
|
||||
foreach (var paragraph in body.Elements<Paragraph>())
|
||||
{
|
||||
var paragraphText = paragraph.InnerText?.Trim();
|
||||
if (!string.IsNullOrEmpty(paragraphText))
|
||||
{
|
||||
// 过滤掉不应该出现的内容
|
||||
if (paragraphText.Contains("CITATION") ||
|
||||
paragraphText.StartsWith("right-") ||
|
||||
paragraphText.StartsWith("left-") ||
|
||||
paragraphText.StartsWith("top-") ||
|
||||
paragraphText.StartsWith("bottom-"))
|
||||
// 增强过滤逻辑,过滤掉不应该出现的内容
|
||||
if (ShouldSkipParagraph(paragraphText))
|
||||
{
|
||||
_logger.LogDebug("跳过不应该的内容: {Text}", paragraphText);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 清理段落文本,移除不需要的内容
|
||||
var cleanedText = CleanParagraphText(paragraphText);
|
||||
if (string.IsNullOrEmpty(cleanedText))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var paragraphData = new ParagraphData
|
||||
{
|
||||
Text = paragraphText,
|
||||
Text = cleanedText,
|
||||
Style = DetermineParagraphStyle(paragraph),
|
||||
FontSize = GetParagraphFontSize(paragraph),
|
||||
Alignment = GetParagraphAlignment(paragraph),
|
||||
@ -476,7 +428,7 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
GetParagraphPosition(paragraph, paragraphData);
|
||||
|
||||
_logger.LogDebug("提取段落: Text={Text}, X={X}, Y={Y}",
|
||||
paragraphText, paragraphData.PositionX, paragraphData.PositionY);
|
||||
cleanedText, paragraphData.PositionX, paragraphData.PositionY);
|
||||
|
||||
documentData.Paragraphs.Add(paragraphData);
|
||||
}
|
||||
@ -646,6 +598,342 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
return "black";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否应该跳过该段落
|
||||
/// </summary>
|
||||
private bool ShouldSkipParagraph(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return true;
|
||||
|
||||
// 过滤掉包含明显内部元数据的内容
|
||||
var lowerText = text.ToLower();
|
||||
|
||||
// 过滤引用相关内容
|
||||
if (lowerText.Contains("citation")) return true;
|
||||
|
||||
// 过滤纯数字的定位信息
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d+$")) return true;
|
||||
|
||||
// 过滤定位前缀(left-, right-, top-, bottom-)
|
||||
if (text.StartsWith("right-") || text.StartsWith("left-") ||
|
||||
text.StartsWith("top-") || text.StartsWith("bottom-")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理段落文本,移除不需要的内容
|
||||
/// </summary>
|
||||
private string CleanParagraphText(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return text;
|
||||
|
||||
var cleaned = text;
|
||||
|
||||
// 移除连续的数字序列(定位信息)
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"\d{6,}", "");
|
||||
|
||||
// 移除 left-xxx, right-xxx, top-xxx, bottom-xxx 这类定位信息
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"(left-|right-|top-|bottom-)\d+", "");
|
||||
|
||||
// 移除重复的占位符(如 {{单位}}00{{单位}})
|
||||
var placeholderPattern = @"\{\{([^}]+)\}\}";
|
||||
var matches = System.Text.RegularExpressions.Regex.Matches(cleaned, placeholderPattern);
|
||||
if (matches.Count > 1)
|
||||
{
|
||||
// 保留第一个占位符,移除后续重复的
|
||||
var firstMatch = matches[0];
|
||||
cleaned = cleaned.Substring(0, firstMatch.Index + firstMatch.Length);
|
||||
}
|
||||
|
||||
// 移除残留的 "00" 这类分隔符
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"00+", "");
|
||||
|
||||
// 移除首尾空格
|
||||
cleaned = cleaned.Trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提取 Drawing 对象(浮于文字上方的文本框)
|
||||
/// </summary>
|
||||
private void ExtractDrawingObjects(Body body, DocumentData documentData)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 查找所有包含 Drawing 的段落
|
||||
foreach (var paragraph in body.Elements<Paragraph>())
|
||||
{
|
||||
// 查找段落中的 Drawing 元素
|
||||
var drawings = paragraph.Descendants<DocumentFormat.OpenXml.Wordprocessing.Drawing>().ToList();
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
ExtractTextFromDrawing(drawing, documentData);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "提取 Drawing 对象失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 Drawing 对象中提取文本和位置信息
|
||||
/// </summary>
|
||||
private void ExtractTextFromDrawing(DocumentFormat.OpenXml.Wordprocessing.Drawing drawing, DocumentData documentData)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 尝试从 Drawing 对象中提取文本框内容
|
||||
var textBoxContents = drawing.Descendants<DocumentFormat.OpenXml.Wordprocessing.TextBoxContent>().ToList();
|
||||
|
||||
foreach (var textBoxContent in textBoxContents)
|
||||
{
|
||||
// 提取文本框中的所有段落
|
||||
var paragraphs = textBoxContent.Elements<Paragraph>().ToList();
|
||||
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
var paragraphText = paragraph.InnerText?.Trim();
|
||||
if (!string.IsNullOrEmpty(paragraphText))
|
||||
{
|
||||
// 增强过滤逻辑,过滤掉不应该出现的内容
|
||||
if (ShouldSkipParagraph(paragraphText))
|
||||
{
|
||||
_logger.LogDebug("跳过 Drawing 对象中的内容: {Text}", paragraphText);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 清理段落文本,移除不需要的内容
|
||||
var cleanedText = CleanParagraphText(paragraphText);
|
||||
if (string.IsNullOrEmpty(cleanedText))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var paragraphData = new ParagraphData
|
||||
{
|
||||
Text = cleanedText,
|
||||
Style = DetermineParagraphStyle(paragraph),
|
||||
FontSize = GetParagraphFontSize(paragraph),
|
||||
Alignment = GetParagraphAlignment(paragraph),
|
||||
IsBold = IsParagraphBold(paragraph),
|
||||
FontColor = GetParagraphColor(paragraph)
|
||||
};
|
||||
|
||||
// 尝试从 Drawing 对象中提取位置信息
|
||||
ExtractPositionFromDrawing(drawing, paragraphData);
|
||||
|
||||
_logger.LogDebug("从 Drawing 对象提取段落: Text={Text}, X={X}, Y={Y}",
|
||||
cleanedText, paragraphData.PositionX, paragraphData.PositionY);
|
||||
|
||||
documentData.Paragraphs.Add(paragraphData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "从 Drawing 对象提取文本失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 Drawing 对象中提取位置信息
|
||||
/// </summary>
|
||||
private void ExtractPositionFromDrawing(DocumentFormat.OpenXml.Wordprocessing.Drawing drawing, ParagraphData paragraphData)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("开始解析 Drawing 对象位置信息");
|
||||
|
||||
var outerXml = drawing.OuterXml;
|
||||
_logger.LogDebug("Drawing 对象原始 XML (前800字符): {Xml}", outerXml.Substring(0, Math.Min(800, outerXml.Length)));
|
||||
|
||||
// 方法1: 查找 simplePos 属性(通常用于绝对定位)
|
||||
ExtractSimplePosition(outerXml, paragraphData);
|
||||
|
||||
// 方法2: 如果 simplePos 没有值,查找 positionOffset 元素
|
||||
if (paragraphData.PositionX == null || paragraphData.PositionY == null)
|
||||
{
|
||||
ExtractPositionOffset(outerXml, paragraphData);
|
||||
}
|
||||
|
||||
// 注意:不使用 distL, distT 等距离属性
|
||||
// 这些属性不是文本框在页面上的坐标,而是:
|
||||
// - 要么是文本框的内边距(文本到边框的距离)
|
||||
// - 要么是文本框到页面边距的距离(而不是绝对坐标)
|
||||
// 我们目前使用写死定位方式,不需要依赖这些值
|
||||
|
||||
_logger.LogDebug("最终提取的位置 - X: {X}, Y: {Y}", paragraphData.PositionX, paragraphData.PositionY);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "从 Drawing 对象提取位置信息失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 XML 中提取 simplePos 属性
|
||||
/// </summary>
|
||||
private void ExtractSimplePosition(string xml, ParagraphData paragraphData)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 查找 simplePos:x 和 simplePos:y 属性
|
||||
var xMatch = System.Text.RegularExpressions.Regex.Match(xml, @"simplePos:x=[""'](\d+)[""']");
|
||||
var yMatch = System.Text.RegularExpressions.Regex.Match(xml, @"simplePos:y=[""'](\d+)[""']");
|
||||
|
||||
if (xMatch.Success)
|
||||
{
|
||||
if (long.TryParse(xMatch.Groups[1].Value, out long x))
|
||||
{
|
||||
paragraphData.PositionX = EmuToPoints(x);
|
||||
_logger.LogDebug("simplePos X (EMU): {EMU}, (点): {Points}", x, paragraphData.PositionX);
|
||||
}
|
||||
}
|
||||
|
||||
if (yMatch.Success)
|
||||
{
|
||||
if (long.TryParse(yMatch.Groups[1].Value, out long y))
|
||||
{
|
||||
paragraphData.PositionY = EmuToPoints(y);
|
||||
_logger.LogDebug("simplePos Y (EMU): {EMU}, (点): {Points}", y, paragraphData.PositionY);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "提取 simplePos 失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 XML 中提取 positionOffset 元素
|
||||
/// </summary>
|
||||
private void ExtractPositionOffset(string xml, ParagraphData paragraphData)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 分别提取 positionH 和 positionV 中的 posOffset 和 relativeFrom
|
||||
// 这样可以更准确地获取 X 和 Y 坐标及其参照对象
|
||||
var positionHPattern = @"<wp:positionH[^>]*relativeFrom=[""']([^""']+)[""'][^>]*>(?:\s*<wp:posOffset[^>]*>(\d+)</wp:posOffset>\s*)</wp:positionH>";
|
||||
var positionHMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
xml,
|
||||
positionHPattern,
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
var positionVPattern = @"<wp:positionV[^>]*relativeFrom=[""']([^""']+)[""'][^>]*>(?:\s*<wp:posOffset[^>]*>(\d+)</wp:posOffset>\s*)</wp:positionV>";
|
||||
var positionVMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
xml,
|
||||
positionVPattern,
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
// 日志输出参照对象信息
|
||||
if (positionHMatch.Success)
|
||||
{
|
||||
_logger.LogDebug("positionH 参照对象: {RelativeFrom}", positionHMatch.Groups[1].Value);
|
||||
}
|
||||
if (positionVMatch.Success)
|
||||
{
|
||||
_logger.LogDebug("positionV 参照对象: {RelativeFrom}", positionVMatch.Groups[1].Value);
|
||||
}
|
||||
|
||||
if (positionHMatch.Success && paragraphData.PositionX == null)
|
||||
{
|
||||
if (long.TryParse(positionHMatch.Groups[2].Value, out long x))
|
||||
{
|
||||
paragraphData.PositionX = EmuToPoints(x);
|
||||
_logger.LogDebug("positionH posOffset (EMU): {EMU}, (点): {Points}", x, paragraphData.PositionX);
|
||||
}
|
||||
}
|
||||
|
||||
if (positionVMatch.Success && paragraphData.PositionY == null)
|
||||
{
|
||||
if (long.TryParse(positionVMatch.Groups[2].Value, out long y))
|
||||
{
|
||||
paragraphData.PositionY = EmuToPoints(y);
|
||||
_logger.LogDebug("positionV posOffset (EMU): {EMU}, (点): {Points}", y, paragraphData.PositionY);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果上面的方法失败,尝试简单的正则表达式匹配
|
||||
if (!positionHMatch.Success || !positionVMatch.Success)
|
||||
{
|
||||
_logger.LogDebug("尝试备用方案:简单匹配 posOffset");
|
||||
var allPosOffsetMatches = System.Text.RegularExpressions.Regex.Matches(xml, @"<wp:posOffset[^>]*>(\d+)</wp:posOffset>");
|
||||
|
||||
if (allPosOffsetMatches.Count >= 2)
|
||||
{
|
||||
if (long.TryParse(allPosOffsetMatches[0].Groups[1].Value, out long x) && paragraphData.PositionX == null)
|
||||
{
|
||||
paragraphData.PositionX = EmuToPoints(x);
|
||||
_logger.LogDebug("备用方案 - posOffset X (EMU): {EMU}, (点): {Points}", x, paragraphData.PositionX);
|
||||
}
|
||||
|
||||
if (long.TryParse(allPosOffsetMatches[1].Groups[1].Value, out long y) && paragraphData.PositionY == null)
|
||||
{
|
||||
paragraphData.PositionY = EmuToPoints(y);
|
||||
_logger.LogDebug("备用方案 - posOffset Y (EMU): {EMU}, (点): {Points}", y, paragraphData.PositionY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "提取 positionOffset 失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 XML 中提取 distL, distT 等距离属性
|
||||
/// </summary>
|
||||
private void ExtractDistanceAttributes(string xml, ParagraphData paragraphData)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 查找 distL (left distance) 和 distT (top distance)
|
||||
var distLMatch = System.Text.RegularExpressions.Regex.Match(xml, @"distL=[""'](\d+)[""']");
|
||||
var distTMatch = System.Text.RegularExpressions.Regex.Match(xml, @"distT=[""'](\d+)[""']");
|
||||
|
||||
if (distLMatch.Success && paragraphData.PositionX == null)
|
||||
{
|
||||
if (long.TryParse(distLMatch.Groups[1].Value, out long x))
|
||||
{
|
||||
paragraphData.PositionX = EmuToPoints(x);
|
||||
_logger.LogDebug("distL X (EMU): {EMU}, (点): {Points}", x, paragraphData.PositionX);
|
||||
}
|
||||
}
|
||||
|
||||
if (distTMatch.Success && paragraphData.PositionY == null)
|
||||
{
|
||||
if (long.TryParse(distTMatch.Groups[1].Value, out long y))
|
||||
{
|
||||
paragraphData.PositionY = EmuToPoints(y);
|
||||
_logger.LogDebug("distT Y (EMU): {EMU}, (点): {Points}", y, paragraphData.PositionY);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "提取距离属性失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EMU (English Metric Unit) 转换为点
|
||||
/// 1 英寸 = 914400 EMU
|
||||
/// 1 英寸 = 72 点
|
||||
/// 所以:1 EMU = 72 / 914400 点 = 1 / 12700 点
|
||||
/// </summary>
|
||||
private double EmuToPoints(long emu)
|
||||
{
|
||||
return emu / 12700.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取段落位置信息
|
||||
/// </summary>
|
||||
@ -745,10 +1033,10 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
.FitArea();
|
||||
}
|
||||
|
||||
// 使用 Column 来放置所有内容,但主要依赖备用位置(基于标准证书模板)
|
||||
// 使用 Column 来放置所有内容
|
||||
page.Content().Column(column =>
|
||||
{
|
||||
// 首先根据段落信息进行精确定位
|
||||
// 根据段落信息进行定位(使用从Word文档提取的位置信息)
|
||||
foreach (var paragraph in documentData.Paragraphs)
|
||||
{
|
||||
var fontSize = paragraph.FontSize ?? 14;
|
||||
@ -758,14 +1046,9 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
_logger.LogDebug("处理段落: Text={Text}, X={X}, Y={Y}, FontSize={FontSize}, Bold={IsBold}",
|
||||
paragraph.Text, paragraph.PositionX, paragraph.PositionY, fontSize, isBold);
|
||||
|
||||
// 使用内容判断位置(基于标准证书模板,这是最可靠的)
|
||||
var (positionX, positionY) = GetTextPosition(paragraph);
|
||||
|
||||
_logger.LogDebug("备用位置 - X={X}, Y={Y}", positionX, positionY);
|
||||
|
||||
// 总是使用备用位置(基于标准证书模板)
|
||||
float x = (float)positionX;
|
||||
float y = (float)positionY;
|
||||
// 直接使用从Word文档提取的位置信息
|
||||
float x = (float)(paragraph.PositionX ?? 50);
|
||||
float y = (float)(paragraph.PositionY ?? 50);
|
||||
|
||||
_logger.LogDebug("最终位置 - X={X}, Y={Y}", x, y);
|
||||
|
||||
@ -800,68 +1083,7 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
await Task.CompletedTask; // 为了保持异步签名
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据段落内容确定文本位置(基于A4页面210x297mm,坐标单位为点)
|
||||
/// </summary>
|
||||
private (double X, double Y) GetTextPosition(ParagraphData paragraph)
|
||||
{
|
||||
// 根据文本内容和样式判断位置
|
||||
var text = paragraph.Text;
|
||||
|
||||
// 根据证书模板的布局进行定位(A4页面,左上角为原点)
|
||||
// 调整坐标以更好地匹配证书模板
|
||||
if (text.Contains("第九届上海国际交互绳大奖赛"))
|
||||
{
|
||||
return (150, 80); // 顶部标题位置(居中)
|
||||
}
|
||||
else if (text.Contains("Double Dutch Contest Shanghai"))
|
||||
{
|
||||
return (150, 120); // 英文标题位置(居中)
|
||||
}
|
||||
else if (text.Contains("CERTIFICATE"))
|
||||
{
|
||||
return (250, 200); // 证书标题位置(居中)
|
||||
}
|
||||
else if (text.Contains("比赛证书"))
|
||||
{
|
||||
return (250, 240); // 中文证书标题位置(居中)
|
||||
}
|
||||
else if (text.Contains("参赛单位"))
|
||||
{
|
||||
return (100, 320); // 参赛单位标签位置
|
||||
}
|
||||
else if (text.Contains("{{danwei}}"))
|
||||
{
|
||||
return (200, 320); // 参赛单位值位置
|
||||
}
|
||||
else if (text.Contains("参赛运动员"))
|
||||
{
|
||||
return (100, 370); // 参赛运动员标签位置
|
||||
}
|
||||
else if (text.Contains("{{name}}"))
|
||||
{
|
||||
return (200, 370); // 参赛运动员值位置
|
||||
}
|
||||
else if (text.Contains("项目组别"))
|
||||
{
|
||||
return (100, 420); // 项目组别标签位置
|
||||
}
|
||||
else if (text.Contains("名次"))
|
||||
{
|
||||
return (100, 470); // 名次标签位置
|
||||
}
|
||||
else if (text.Contains("第九届上海国际交互绳大奖赛组委会"))
|
||||
{
|
||||
return (150, 550); // 底部组织机构位置(居中)
|
||||
}
|
||||
else if (text.Contains("二零二五年十一月"))
|
||||
{
|
||||
return (250, 590); // 日期位置(居中)
|
||||
}
|
||||
|
||||
// 默认位置
|
||||
return (50, 50);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 确定段落样式
|
||||
@ -892,40 +1114,7 @@ public class CertificateGenerateService : IDynamicApiController, ITransient
|
||||
return "normal";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取LibreOffice可执行文件路径
|
||||
/// </summary>
|
||||
private string GetLibreOfficePath()
|
||||
{
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// Windows
|
||||
@"C:\Program Files\LibreOffice\program\soffice.exe",
|
||||
@"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
|
||||
@"C:\Program Files\LibreOffice\program\libreoffice.exe",
|
||||
@"C:\Program Files (x86)\LibreOffice\program\libreoffice.exe",
|
||||
|
||||
// Linux
|
||||
"/usr/bin/libreoffice",
|
||||
"/usr/bin/soffice",
|
||||
"/opt/libreoffice/program/soffice",
|
||||
|
||||
// macOS
|
||||
"/Applications/LibreOffice.app/Contents/MacOS/soffice"
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths)
|
||||
{
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
_logger.LogInformation("找到LibreOffice: {Path}", path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找不到,尝试使用系统PATH中的命令
|
||||
return OperatingSystem.IsWindows() ? "soffice" : "libreoffice";
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 保存PDF文件
|
||||
|
||||
@ -618,20 +618,20 @@ public class EventScoreService : BaseService<EventResult>, ITransient
|
||||
{
|
||||
["name"] = person.Name,
|
||||
["姓名"] = person.Name,
|
||||
["group"] = person.GroupName,
|
||||
["组别"] = person.GroupName,
|
||||
["项目组别"] = person.GroupName,
|
||||
["project"] = result.ProjectTypeName,
|
||||
["项目"] = result.ProjectTypeName,
|
||||
//["group"] = person.GroupName,
|
||||
//["组别"] = person.GroupName,
|
||||
["项目组别"] = $"{person.ProjectName}-{person.GroupName}",
|
||||
//["project"] = result.ProjectTypeName,
|
||||
//["项目"] = result.ProjectTypeName,
|
||||
["score"] = result.Score,
|
||||
["成绩"] = result.Score,
|
||||
["rank"] = result.Rank.ToString(),
|
||||
["名次"] = GetRankText(result.Rank),
|
||||
["danwei"] = person.ProjectName ?? "个人",
|
||||
["单位"] = person.ProjectName ?? "个人",
|
||||
["参赛单位"] = person.ProjectName ?? "个人"
|
||||
["单位"] = "上海跃动文化科技有限公司",
|
||||
["参赛单位"] = "上海跃动文化科技有限公司"
|
||||
};
|
||||
|
||||
|
||||
// 添加日期信息
|
||||
var now = DateTime.Now;
|
||||
data["year"] = now.Year.ToString();
|
||||
|
||||
@ -802,9 +802,14 @@
|
||||
批量生成PDF证书
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ConvertDocToPdf(System.IO.Stream,System.Byte[])">
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ConvertDocToPdf(System.IO.Stream,System.Byte[],System.Collections.Generic.Dictionary{System.String,System.String},System.Collections.Generic.Dictionary{System.String,System.String},System.Collections.Generic.Dictionary{System.String,System.String})">
|
||||
<summary>
|
||||
转换DOC为PDF(优先使用LibreOffice,后备使用QuestPDF)
|
||||
转换DOC为PDF(使用QuestPDF)
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ReplacePlaceholdersInDocumentData(YD_Event.Application.Certificate.DocumentData,System.Collections.Generic.Dictionary{System.String,System.String},System.Collections.Generic.Dictionary{System.String,System.String},System.Collections.Generic.Dictionary{System.String,System.String})">
|
||||
<summary>
|
||||
替换DocumentData中的占位符
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ExtractDocumentData(System.IO.Stream)">
|
||||
@ -837,6 +842,54 @@
|
||||
获取段落字体颜色
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ShouldSkipParagraph(System.String)">
|
||||
<summary>
|
||||
判断是否应该跳过该段落
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.CleanParagraphText(System.String)">
|
||||
<summary>
|
||||
清理段落文本,移除不需要的内容
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ExtractDrawingObjects(DocumentFormat.OpenXml.Wordprocessing.Body,YD_Event.Application.Certificate.DocumentData)">
|
||||
<summary>
|
||||
提取 Drawing 对象(浮于文字上方的文本框)
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ExtractTextFromDrawing(DocumentFormat.OpenXml.Wordprocessing.Drawing,YD_Event.Application.Certificate.DocumentData)">
|
||||
<summary>
|
||||
从 Drawing 对象中提取文本和位置信息
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ExtractPositionFromDrawing(DocumentFormat.OpenXml.Wordprocessing.Drawing,YD_Event.Application.Certificate.ParagraphData)">
|
||||
<summary>
|
||||
从 Drawing 对象中提取位置信息
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ExtractSimplePosition(System.String,YD_Event.Application.Certificate.ParagraphData)">
|
||||
<summary>
|
||||
从 XML 中提取 simplePos 属性
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ExtractPositionOffset(System.String,YD_Event.Application.Certificate.ParagraphData)">
|
||||
<summary>
|
||||
从 XML 中提取 positionOffset 元素
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.ExtractDistanceAttributes(System.String,YD_Event.Application.Certificate.ParagraphData)">
|
||||
<summary>
|
||||
从 XML 中提取 distL, distT 等距离属性
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.EmuToPoints(System.Int64)">
|
||||
<summary>
|
||||
EMU (English Metric Unit) 转换为点
|
||||
1 英寸 = 914400 EMU
|
||||
1 英寸 = 72 点
|
||||
所以:1 EMU = 72 / 914400 点 = 1 / 12700 点
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.GetParagraphPosition(DocumentFormat.OpenXml.Wordprocessing.Paragraph,YD_Event.Application.Certificate.ParagraphData)">
|
||||
<summary>
|
||||
获取段落位置信息
|
||||
@ -847,21 +900,11 @@
|
||||
使用QuestPDF生成PDF
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.GetTextPosition(YD_Event.Application.Certificate.ParagraphData)">
|
||||
<summary>
|
||||
根据段落内容确定文本位置(基于A4页面210x297mm,坐标单位为点)
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.DetermineParagraphStyle(DocumentFormat.OpenXml.Wordprocessing.Paragraph)">
|
||||
<summary>
|
||||
确定段落样式
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.GetLibreOfficePath">
|
||||
<summary>
|
||||
获取LibreOffice可执行文件路径
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:YD_Event.Application.Certificate.CertificateGenerateService.SavePdfFile(System.IO.Stream,YD_Event.Core.Entities.Certificate,YD_Event.Core.Entities.CertificateTemplate)">
|
||||
<summary>
|
||||
保存PDF文件
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user