赛事证书

This commit is contained in:
马梓彭 2026-04-16 02:56:50 +08:00
parent 7fd1cd1435
commit 2ee874554d
3 changed files with 502 additions and 270 deletions

View File

@ -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文件

View File

@ -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();

View File

@ -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文件