YD_Event.Admin/YD_Event.Application/Certificate/CertificateGenerateService.cs
2026-04-16 11:08:23 +08:00

1388 lines
54 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using YD_Event.Application.Certificate.Dtos;
using YD_Event.Core.Entities;
using YD_Event.Core.Domain.Certificate;
using SqlSugar;
using Mapster;
using Microsoft.Extensions.Options;
using YD_Event.Core.Options;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
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;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using YD_Event.Application.Common;
namespace YD_Event.Application.Certificate;
/// <summary>
/// 证书生成服务
/// </summary>
public class CertificateGenerateService : IDynamicApiController, ITransient
{
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Certificate> _certificateRepository;
private readonly ISqlSugarRepository<CertificateTemplate> _templateRepository;
private readonly ISqlSugarRepository<CertificatePlaceholder> _placeholderRepository;
private readonly FileService _fileService;
private readonly IOptionsMonitor<OssConnectionOptions> _ossOptionsMonitor;
private readonly IIdGenerator _idGenerator;
private readonly IWebHostEnvironment _webHostEnvironment;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<CertificateGenerateService> _logger;
private readonly DocxTemplateProcessor _docxProcessor;
private readonly QRCodeService _qrCodeService;
public CertificateGenerateService(
ISqlSugarRepository<YD_Event.Core.Entities.Certificate> certificateRepository,
ISqlSugarRepository<CertificateTemplate> templateRepository,
ISqlSugarRepository<CertificatePlaceholder> placeholderRepository,
FileService fileService,
IOptionsMonitor<OssConnectionOptions> ossOptionsMonitor,
IIdGenerator idGenerator,
IWebHostEnvironment webHostEnvironment,
IHttpContextAccessor httpContextAccessor,
ILogger<CertificateGenerateService> logger,
DocxTemplateProcessor docxProcessor,
QRCodeService qrCodeService)
{
_certificateRepository = certificateRepository;
_templateRepository = templateRepository;
_placeholderRepository = placeholderRepository;
_fileService = fileService;
_ossOptionsMonitor = ossOptionsMonitor;
_idGenerator = idGenerator;
_webHostEnvironment = webHostEnvironment;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_docxProcessor = docxProcessor;
_qrCodeService = qrCodeService;
}
/// <summary>
/// 生成PDF证书
/// </summary>
[DisplayName("生成PDF证书")]
public async Task<PdfCertificateGenerationResult> GeneratePdf(GeneratePdfCertificateInput input)
{
var stopwatch = Stopwatch.StartNew();
var result = new PdfCertificateGenerationResult
{
GeneratedTime = DateTime.Now,
IsSuccess = true
};
try
{
// 1. 获取证书信息
var certificate = await _certificateRepository.GetByIdAsync(input.CertificateId);
if (certificate == null)
{
result.IsSuccess = false;
result.ErrorMessage = "证书不存在";
return result;
}
if (certificate.Status != Core.Enum.AvailabilityStatus.Enable)
{
result.IsSuccess = false;
result.ErrorMessage = "证书已禁用";
return result;
}
// 2. 获取模板
CertificateTemplate template;
if (input.TemplateId.HasValue)
{
template = await _templateRepository.GetByIdAsync(input.TemplateId.Value);
}
else
{
template = await _templateRepository.GetByIdAsync(certificate.TemplateId);
}
if (template == null)
{
result.IsSuccess = false;
result.ErrorMessage = "未找到有效证书模板";
return result;
}
// 3. 检查模板类型
if (template.TemplateType != "Doc")
{
result.IsSuccess = false;
result.ErrorMessage = "指定的模板不是DOC类型";
return result;
}
// 4. 获取占位符配置
var placeholders = await _placeholderRepository.AsQueryable()
.Where(x => x.TemplateId == template.Id)
.OrderBy(x => x.Sort)
.ToListAsync();
// 5. 验证必填数据
var requiredPlaceholders = placeholders.Where(x => x.IsRequired).Select(x => x.PlaceholderKey).ToList();
var validationResult = _docxProcessor.ValidateTemplateData(requiredPlaceholders, input.Data);
if (!validationResult.IsValid)
{
result.IsSuccess = false;
result.ErrorMessage = $"缺少必填数据: {string.Join(", ", validationResult.MissingPlaceholders)}";
return result;
}
// 6. 下载DOC模板
using var httpClient = new HttpClient();
var templateStream = new MemoryStream(await httpClient.GetByteArrayAsync(template.DocxTemplateUrl));
// 7. 生成二维码数据(用于DOC模板中的占位符)
var qrCodeData = new Dictionary<string, string>();
if (input.GenerateQRCode)
{
var certificateUrl = GenerateCertificateUrl(input.CertificateId);
qrCodeData["CertificateQR"] = certificateUrl;
qrCodeData["VerificationQR"] = certificateUrl + "/verify";
}
// 8. 设置占位符映射(支持中文显示名 -> 英文键名)
var placeholderMapping = new Dictionary<string, string>();
foreach (var placeholder in placeholders)
{
if (!string.IsNullOrWhiteSpace(placeholder.DisplayName))
{
placeholderMapping[placeholder.DisplayName] = placeholder.PlaceholderKey;
}
}
_docxProcessor.SetPlaceholderMapping(placeholderMapping);
_logger.LogInformation("设置占位符映射完成. 映射数量: {Count}", placeholderMapping.Count);
// 9. 处理模板
var processedStream = _docxProcessor.ProcessTemplate(templateStream, input.Data, qrCodeData);
// 10. 先生成证书保存地址(预设的PDF地址)
var pdfUrl = GeneratePdfUrl(input.CertificateId, certificate, template);
// 11. 生成二维码图片(使用预设的PDF地址)
byte[] qrCodeImage = null;
if (input.GenerateQRCode)
{
using var qrStream = _qrCodeService.GenerateQRCode(pdfUrl, input.QRCodeSize);
using var memoryStream = new MemoryStream();
qrStream.CopyTo(memoryStream);
qrCodeImage = memoryStream.ToArray();
}
// 12. 转换为PDF(包含二维码和数据替换)
var pdfStream = await ConvertDocToPdf(processedStream, qrCodeImage, input.Data, qrCodeData, placeholderMapping);
// 13. 保存PDF文件到预设地址
await SavePdfFileToUrl(pdfStream, pdfUrl);
// 14. 生成二维码图片(如果需要)
string qrCodeUrl = null;
if (input.GenerateQRCode)
{
qrCodeUrl = await GenerateQRCodeImage(input.CertificateId, input.QRCodeSize);
}
// 15. 设置结果
result.PdfUrl = pdfUrl;
result.QRCodeUrl = qrCodeUrl;
result.VerificationUrl = GenerateCertificateUrl(input.CertificateId);
result.Warnings.AddRange(validationResult.Warnings);
_logger.LogInformation("PDF证书生成成功. CertificateId: {CertificateId}, Url: {Url}",
input.CertificateId, pdfUrl);
}
catch (Exception ex)
{
result.IsSuccess = false;
result.ErrorMessage = ex.Message;
_logger.LogError(ex, "PDF证书生成失败. CertificateId: {CertificateId}", input.CertificateId);
}
finally
{
stopwatch.Stop();
result.GenerationTimeMs = stopwatch.ElapsedMilliseconds;
}
return result;
}
/// <summary>
/// 批量生成PDF证书
/// </summary>
[DisplayName("批量生成PDF证书")]
public async Task<BatchPdfCertificateGenerationResult> BatchGeneratePdf(BatchGeneratePdfCertificateInput input)
{
var result = new BatchPdfCertificateGenerationResult
{
StartTime = DateTime.Now,
TotalCount = input.DataList.Count
};
_logger.LogInformation("开始批量生成PDF证书. Count: {Count}", input.DataList.Count);
foreach (var data in input.DataList)
{
var generateInput = new GeneratePdfCertificateInput
{
CertificateId = input.CertificateId,
TemplateId = input.TemplateId,
Data = data,
GenerateQRCode = input.GenerateQRCode,
QRCodeSize = input.QRCodeSize
};
var generateResult = await GeneratePdf(generateInput);
if (generateResult.IsSuccess)
{
result.SuccessResults.Add(generateResult);
}
else
{
result.FailedResults.Add(generateResult);
}
}
result.EndTime = DateTime.Now;
_logger.LogInformation("批量生成PDF证书完成. Total: {Total}, Success: {Success}, Failed: {Failed}",
result.TotalCount, result.SuccessCount, result.FailedCount);
return result;
}
/// <summary>
/// 转换DOC为PDF(使用QuestPDF)
/// </summary>
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)
{
_logger.LogInformation("开始DOCX转PDF转换(使用QuestPDF)");
// 重置流位置
docStream.Position = 0;
// 使用QuestPDF许可证
QuestPDF.Settings.License = LicenseType.Community;
// 解析DOCX内容
var documentData = await ExtractDocumentData(docStream);
// 添加二维码图片
if (qrCodeImage != null)
{
documentData.QRCodeImage = qrCodeImage;
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>
/// 替换DocumentData中的占位符
/// </summary>
private void ReplacePlaceholdersInDocumentData(DocumentData documentData, Dictionary<string, string> data, Dictionary<string, string> qrCodeData, Dictionary<string, string> placeholderMapping)
{
const string placeholderPattern = @"\{\{([^}]+)\}\}";
// 记录占位符映射信息(便于调试)
if (placeholderMapping != null && placeholderMapping.Count > 0)
{
_logger.LogDebug("占位符映射配置: {Mapping}", string.Join(", ", placeholderMapping.Select(kvp => $"{kvp.Key}->{kvp.Value}")));
}
// 记录数据字典中的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>
private async Task<DocumentData> ExtractDocumentData(Stream docStream)
{
var documentData = new DocumentData();
var processedTexts = new HashSet<string>(); // 记录已处理的文本,避免重复
using var wordprocessingDocument = WordprocessingDocument.Open(docStream, false);
var body = wordprocessingDocument.MainDocumentPart?.Document?.Body;
// 提取背景图片
await ExtractBackgroundImage(wordprocessingDocument, documentData);
if (body != null)
{
// 首先处理 Drawing 对象(浮于文字上方的文本框)
ExtractDrawingObjects(body, documentData, processedTexts);
// 提取段落(包含位置信息),但跳过包含Drawing对象的段落(避免重复)
foreach (var paragraph in body.Elements<Paragraph>())
{
// 检查段落是否包含 Drawing 对象,如果是则跳过(已在 ExtractDrawingObjects 中处理)
var hasDrawing = paragraph.Descendants<DocumentFormat.OpenXml.Wordprocessing.Drawing>().Any();
if (hasDrawing)
{
_logger.LogDebug("跳过包含 Drawing 对象的段落");
continue;
}
var paragraphText = paragraph.InnerText?.Trim();
if (!string.IsNullOrEmpty(paragraphText))
{
// 增强过滤逻辑,过滤掉不应该出现的内容
if (ShouldSkipParagraph(paragraphText))
{
_logger.LogDebug("跳过不应该的内容: {Text}", paragraphText);
continue;
}
// 清理段落文本,移除不需要的内容
var cleanedText = CleanParagraphText(paragraphText);
if (string.IsNullOrEmpty(cleanedText))
{
continue;
}
// 检查是否已经处理过相同文本,避免重复
if (processedTexts.Contains(cleanedText))
{
_logger.LogDebug("跳过重复文本: {Text}", cleanedText);
continue;
}
var paragraphData = new ParagraphData
{
Text = cleanedText,
Style = DetermineParagraphStyle(paragraph),
FontSize = GetParagraphFontSize(paragraph),
Alignment = GetParagraphAlignment(paragraph),
IsBold = IsParagraphBold(paragraph),
FontColor = GetParagraphColor(paragraph)
};
// 尝试获取位置信息
GetParagraphPosition(paragraph, paragraphData);
_logger.LogDebug("提取段落: Text={Text}, X={X}, Y={Y}",
cleanedText, paragraphData.PositionX, paragraphData.PositionY);
documentData.Paragraphs.Add(paragraphData);
processedTexts.Add(cleanedText);
}
}
// 提取表格
foreach (var table in body.Elements<Table>())
{
var tableData = new TableData();
foreach (var row in table.Elements<TableRow>())
{
var rowData = new List<string>();
foreach (var cell in row.Elements<TableCell>())
{
var cellText = cell.InnerText?.Trim() ?? string.Empty;
rowData.Add(cellText);
}
if (rowData.Any())
{
tableData.Rows.Add(rowData);
}
}
if (tableData.Rows.Any())
{
documentData.Tables.Add(tableData);
}
}
}
return documentData;
}
/// <summary>
/// 提取背景图片
/// </summary>
private async Task ExtractBackgroundImage(WordprocessingDocument wordDoc, DocumentData documentData)
{
try
{
var mainPart = wordDoc.MainDocumentPart;
if (mainPart != null)
{
// 查找文档中的图片
var imageParts = mainPart.GetPartsOfType<ImagePart>().ToList();
if (imageParts.Any())
{
// 尝试获取第一个图片作为背景图片
var firstImage = imageParts.FirstOrDefault();
if (firstImage != null)
{
using var imageStream = firstImage.GetStream(FileMode.Open, FileAccess.Read);
using var memoryStream = new MemoryStream();
await imageStream.CopyToAsync(memoryStream);
documentData.BackgroundImage = memoryStream.ToArray();
documentData.BackgroundImageMimeType = firstImage.ContentType;
_logger.LogInformation("成功提取背景图片,类型: {Type}, 大小: {Size} bytes",
documentData.BackgroundImageMimeType, documentData.BackgroundImage.Length);
}
}
// 如果没有找到图片,尝试从页眉页脚中查找
if (documentData.BackgroundImage == null)
{
var headerParts = mainPart.HeaderParts;
foreach (var headerPart in headerParts)
{
var headerImages = headerPart.GetPartsOfType<ImagePart>();
foreach (var image in headerImages)
{
using var imageStream = image.GetStream(FileMode.Open, FileAccess.Read);
using var memoryStream = new MemoryStream();
await imageStream.CopyToAsync(memoryStream);
documentData.BackgroundImage = memoryStream.ToArray();
documentData.BackgroundImageMimeType = image.ContentType;
_logger.LogInformation("从页眉中提取背景图片,类型: {Type}, 大小: {Size} bytes",
documentData.BackgroundImageMimeType, documentData.BackgroundImage.Length);
break;
}
if (documentData.BackgroundImage != null) break;
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "提取背景图片失败");
}
}
/// <summary>
/// 获取段落字体大小
/// </summary>
private double? GetParagraphFontSize(Paragraph paragraph)
{
var runProperties = paragraph.Elements<Run>()
.SelectMany(r => r.Elements<RunProperties>())
.FirstOrDefault();
if (runProperties?.FontSize != null)
{
var fontSize = runProperties.FontSize.Val;
if (double.TryParse(fontSize, out double size))
{
return size / 2.0; // OpenXML 中字体大小是半磅为单位
}
}
return null;
}
/// <summary>
/// 获取段落对齐方式
/// </summary>
private string GetParagraphAlignment(Paragraph paragraph)
{
var paragraphProperties = paragraph.ParagraphProperties;
if (paragraphProperties?.Justification != null)
{
var justification = paragraphProperties.Justification.Val?.ToString().ToLower();
return justification switch
{
"center" => "center",
"right" => "right",
"both" => "justify",
_ => "left"
};
}
return "left";
}
/// <summary>
/// 检查段落是否为粗体
/// </summary>
private bool IsParagraphBold(Paragraph paragraph)
{
var runProperties = paragraph.Elements<Run>()
.SelectMany(r => r.Elements<RunProperties>())
.FirstOrDefault();
return runProperties?.Bold != null;
}
/// <summary>
/// 获取段落字体颜色
/// </summary>
private string GetParagraphColor(Paragraph paragraph)
{
var runProperties = paragraph.Elements<Run>()
.SelectMany(r => r.Elements<RunProperties>())
.FirstOrDefault();
if (runProperties?.Color != null)
{
var color = runProperties.Color.Val;
if (!string.IsNullOrEmpty(color))
{
return color;
}
}
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, HashSet<string> processedTexts = null)
{
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, processedTexts);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "提取 Drawing 对象失败");
}
}
/// <summary>
/// 从 Drawing 对象中提取文本和位置信息
/// </summary>
private void ExtractTextFromDrawing(DocumentFormat.OpenXml.Wordprocessing.Drawing drawing, DocumentData documentData, HashSet<string> processedTexts = null)
{
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;
}
// 检查是否已经处理过相同文本,避免重复
if (processedTexts != null && processedTexts.Contains(cleanedText))
{
_logger.LogDebug("跳过 Drawing 对象中的重复文本: {Text}", 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);
if (processedTexts != null)
{
processedTexts.Add(cleanedText);
}
}
}
}
}
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>
private void GetParagraphPosition(Paragraph paragraph, ParagraphData paragraphData)
{
try
{
var paragraphProperties = paragraph.ParagraphProperties;
if (paragraphProperties != null)
{
_logger.LogDebug("处理段落: {Text}", paragraphData.Text);
// 检查是否有绝对定位
var frameProperties = paragraphProperties.Elements<FrameProperties>().FirstOrDefault();
if (frameProperties != null)
{
_logger.LogDebug("找到FrameProperties");
// 提取位置信息
if (frameProperties.Width != null)
{
var widthStr = frameProperties.Width.Value.ToString();
_logger.LogDebug("Width: {Width}", widthStr);
if (uint.TryParse(widthStr, out uint width))
{
paragraphData.PositionX = width;
_logger.LogDebug("设置PositionX: {X}", width);
}
}
if (frameProperties.Height != null)
{
var heightStr = frameProperties.Height.Value.ToString();
_logger.LogDebug("Height: {Height}", heightStr);
if (uint.TryParse(heightStr, out uint height))
{
paragraphData.PositionY = height;
_logger.LogDebug("设置PositionY: {Y}", height);
}
}
}
else
{
_logger.LogDebug("未找到FrameProperties");
}
// 如果没有绝对定位,检查段落缩进
if (paragraphData.PositionX == null)
{
var indentation = paragraphProperties.Elements<Indentation>().FirstOrDefault();
if (indentation != null && indentation.Left != null)
{
var leftStr = indentation.Left.Value.ToString();
_logger.LogDebug("Left Indentation: {Indent}", leftStr);
if (uint.TryParse(leftStr, out uint leftIndent))
{
paragraphData.PositionX = leftIndent / 20; // 转换为点
_logger.LogDebug("通过缩进设置PositionX: {X}", paragraphData.PositionX);
}
}
}
// 检查段落间距
var spacingBetweenLines = paragraphProperties.SpacingBetweenLines;
if (spacingBetweenLines != null)
{
_logger.LogDebug("SpacingBetweenLines: {Spacing}", spacingBetweenLines.ToString());
}
}
_logger.LogDebug("最终位置 - X: {X}, Y: {Y}", paragraphData.PositionX, paragraphData.PositionY);
}
catch (Exception ex)
{
_logger.LogError(ex, "获取段落位置信息失败");
}
}
/// <summary>
/// 使用QuestPDF生成PDF
/// </summary>
private async Task GeneratePdfWithQuestPDF(Stream pdfStream, DocumentData documentData)
{
var questDocument = QuestPDF.Fluent.Document.Create(container =>
{
container.Page(page =>
{
page.Margin(0);
page.Size(PageSizes.A4);
page.DefaultTextStyle(x => x.FontSize(12).FontFamily(QuestPDF.Helpers.Fonts.Calibri));
// 添加背景图片
if (documentData.BackgroundImage != null)
{
page.Background()
.Image(documentData.BackgroundImage)
.FitArea();
}
// 使用 Column 来放置所有内容,但使用 Translate 而非 Padding 定位
page.Content().Column(column =>
{
// 根据段落信息进行定位(使用从Word文档提取的位置信息)
foreach (var paragraph in documentData.Paragraphs)
{
var fontSize = paragraph.FontSize ?? 14;
var isBold = paragraph.IsBold;
// 日志输出段落信息,便于调试
_logger.LogDebug("处理段落: Text={Text}, X={X}, Y={Y}, FontSize={FontSize}, Bold={IsBold}",
paragraph.Text, paragraph.PositionX, paragraph.PositionY, fontSize, isBold);
// 直接使用从Word文档提取的位置信息,并增加110个单位进行调整
float x = (float)(paragraph.PositionX ?? 50) + 90;
float y = (float)(paragraph.PositionY ?? 50) + 50;
_logger.LogDebug("最终位置 - X={X}, Y={Y}", x, y);
// 使用 Translate 而非 Padding 来定位,避免流式布局累加导致分页
var textElement = column.Item().TranslateX(x).TranslateY(y).Text(paragraph.Text)
.FontSize((float)fontSize)
.FontColor(QuestPDF.Helpers.Colors.Black)
.AlignLeft();
if (isBold)
{
textElement.Bold();
}
}
});
// 添加二维码到右下角(使用 Footer)
if (documentData.QRCodeImage != null)
{
float qrSize = 100;
_logger.LogDebug("添加二维码: Size={Size}", qrSize);
page.Footer().AlignRight().PaddingRight(30).PaddingBottom(30)
.Width(qrSize).Height(qrSize)
.Image(documentData.QRCodeImage);
}
});
});
// 生成PDF到流
questDocument.GeneratePdf(pdfStream);
await Task.CompletedTask; // 为了保持异步签名
}
/// <summary>
/// 确定段落样式
/// </summary>
private string DetermineParagraphStyle(Paragraph paragraph)
{
// 检查是否为标题
var paragraphProperties = paragraph.ParagraphProperties;
if (paragraphProperties != null)
{
var style = paragraphProperties.ParagraphStyleId?.Val?.Value?.ToLower();
if (style != null)
{
if (style.Contains("heading") || style.Contains("title"))
{
return "heading";
}
}
}
// 根据文本特征判断
var text = paragraph.InnerText?.Trim() ?? string.Empty;
if (text.Length < 50 && text.Length > 0)
{
return "subheading";
}
return "normal";
}
/// <summary>
/// 保存PDF文件
/// </summary>
private async Task<string> SavePdfFile(Stream pdfStream, YD_Event.Core.Entities.Certificate certificate, CertificateTemplate template)
{
var options = _ossOptionsMonitor.CurrentValue;
var now = DateTime.Today;
string name = $"certificate_{_idGenerator.Encode(_idGenerator.NewLong())}.pdf";
string filePath = $"/certificates/{now.Year}/{now.Month:D2}/{now.Day:D2}/";
string finalUrl = string.Empty;
pdfStream.Position = 0;
if (!options.Enable)
{
// 本地存储
string localDir = string.Concat(options.Bucket?.TrimEnd('/') ?? "", filePath);
string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir.TrimStart('/'));
if (!Directory.Exists(absoluteDir))
{
Directory.CreateDirectory(absoluteDir);
}
string fullPath = Path.Combine(absoluteDir, name);
// 预测最终 URL
var request = _httpContextAccessor.HttpContext?.Request;
if (request != null)
{
finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir.TrimStart('/')}{name}";
}
using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
await pdfStream.CopyToAsync(fileStream);
_logger.LogInformation("PDF文件保存到本地: {FullPath}", fullPath);
}
else
{
// OSS 上传
string fileName = $"{filePath.TrimStart('/')}{name}";
finalUrl = await _fileService.UploadFileStreamAsync(fileName, pdfStream);
_logger.LogInformation("PDF文件上传到OSS: {FileName}", fileName);
}
return finalUrl;
}
/// <summary>
/// 生成二维码图片
/// </summary>
private async Task<string> GenerateQRCodeImage(long certificateId, int size)
{
try
{
var certificateUrl = GenerateCertificateUrl(certificateId);
using var qrStream = _qrCodeService.GenerateQRCode(certificateUrl, size);
return await SaveQRCodeImage(qrStream, certificateId, size);
}
catch (Exception ex)
{
_logger.LogError(ex, "生成二维码图片失败. CertificateId: {CertificateId}", certificateId);
return null;
}
}
/// <summary>
/// 保存二维码图片
/// </summary>
private async Task<string> SaveQRCodeImage(Stream qrStream, long certificateId, int size)
{
var options = _ossOptionsMonitor.CurrentValue;
var now = DateTime.Today;
string name = $"qr_{certificateId}_{_idGenerator.Encode(_idGenerator.NewLong())}";
string filePath = $"/{now.Year}/{now.Month:D2}/{now.Day:D2}/qrcodes/";
string extension = ".png";
string finalUrl = string.Empty;
qrStream.Position = 0;
if (!options.Enable)
{
string localDir = string.Concat(options.Bucket?.TrimEnd('/') ?? "", filePath);
string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir.TrimStart('/'));
if (!Directory.Exists(absoluteDir))
{
Directory.CreateDirectory(absoluteDir);
}
string fullPath = Path.Combine(absoluteDir, $"{name}{extension}");
using var fileStream2 = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
await qrStream.CopyToAsync(fileStream2);
var request = _httpContextAccessor.HttpContext?.Request;
if (request != null)
{
finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir.TrimStart('/')}{name}{extension}";
}
}
else
{
// OSS 上传
string fileName = $"{filePath.TrimStart('/')}{name}{extension}";
finalUrl = await _fileService.UploadFileStreamAsync(fileName, qrStream);
}
return finalUrl;
}
/// <summary>
/// 生成证书URL
/// </summary>
private string GenerateCertificateUrl(long certificateId)
{
var request = _httpContextAccessor.HttpContext?.Request;
if (request == null) return string.Empty;
var baseUrl = $"{request.Scheme}://{request.Host.Value}";
return _qrCodeService.GenerateCertificateUrl(certificateId, baseUrl);
}
/// <summary>
/// 生成PDF保存地址
/// </summary>
private string GeneratePdfUrl(long certificateId, YD_Event.Core.Entities.Certificate certificate, CertificateTemplate template)
{
var options = _ossOptionsMonitor.CurrentValue;
var now = DateTime.Today;
string name = $"certificate_{_idGenerator.Encode(_idGenerator.NewLong())}.pdf";
string filePath = $"/certificates/{now.Year}/{now.Month:D2}/{now.Day:D2}/";
string finalUrl = string.Empty;
if (!options.Enable)
{
// 本地存储
string localDir = string.Concat(options.Bucket?.TrimEnd('/') ?? "", filePath);
// 预测最终 URL
var request = _httpContextAccessor.HttpContext?.Request;
if (request != null)
{
finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir.TrimStart('/')}{name}";
}
}
else
{
// OSS 上传
string fileName = $"{filePath.TrimStart('/')}{name}";
finalUrl = $"{options.Domain.TrimEnd('/')}/{fileName}";
}
return finalUrl;
}
/// <summary>
/// 保存PDF文件到指定URL
/// </summary>
private async Task SavePdfFileToUrl(Stream pdfStream, string targetUrl)
{
var options = _ossOptionsMonitor.CurrentValue;
// 从URL中解析文件路径
var uri = new Uri(targetUrl);
var path = uri.AbsolutePath;
// 移除域名部分,获取相对路径
string relativePath = path;
if (path.StartsWith("/"))
{
relativePath = path.Substring(1);
}
pdfStream.Position = 0;
if (!options.Enable)
{
// 本地存储
string localDir = Path.GetDirectoryName(relativePath);
string fileName = Path.GetFileName(relativePath);
string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir);
if (!Directory.Exists(absoluteDir))
{
Directory.CreateDirectory(absoluteDir);
}
string fullPath = Path.Combine(absoluteDir, fileName);
using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
await pdfStream.CopyToAsync(fileStream);
_logger.LogInformation("PDF文件保存到本地: {FullPath}", fullPath);
}
else
{
// OSS 上传
await _fileService.UploadFileStreamAsync(relativePath, pdfStream);
_logger.LogInformation("PDF文件上传到OSS: {FileName}", relativePath);
}
}
}
/// <summary>
/// 文档数据模型
/// </summary>
public class DocumentData
{
public List<ParagraphData> Paragraphs { get; set; } = new List<ParagraphData>();
public List<TableData> Tables { get; set; } = new List<TableData>();
public byte[] BackgroundImage { get; set; } = null;
public string BackgroundImageMimeType { get; set; } = string.Empty;
public byte[] QRCodeImage { get; set; } = null;
public string QRCodeMimeType { get; set; } = string.Empty;
}
/// <summary>
/// 段落数据模型
/// </summary>
public class ParagraphData
{
public string Text { get; set; } = string.Empty;
public string Style { get; set; } = "normal"; // normal, heading, subheading
public double? PositionX { get; set; } = null;
public double? PositionY { get; set; } = null;
public double? FontSize { get; set; } = null;
public string Alignment { get; set; } = "left"; // left, center, right
public bool IsBold { get; set; } = false;
public string FontColor { get; set; } = "black";
}
/// <summary>
/// 表格数据模型
/// </summary>
public class TableData
{
public List<List<string>> Rows { get; set; } = new List<List<string>>();
}